From 6ecb2c366fe3ebe99cc3dd7950f1f28eec4df1cc Mon Sep 17 00:00:00 2001 From: Benjamin Diedrichsen Date: Tue, 28 Jul 2026 12:18:10 +0200 Subject: [PATCH] [refactor] moving cubes into own package" [fix] default parameter run records parameters in session for replay[fix] remove default parameters for several cubes --- .gitea/workflows/ci.yml | 7 + .gitea/workflows/publish-snapshot.yml | 37 ++- .gitea/workflows/release.yml | 42 ++- .nopyrc.json | 3 +- CLAUDE.md | 182 +++++++++--- DOCS-AUDIT.md | 89 +++++- README.PUBLISH.md | 161 +++++++++-- cubes/user/add/manifest.mjs | 25 -- package.json | 3 +- packages/cubes-core/LICENSE | 21 ++ packages/cubes-core/README.md | 59 ++++ .../cubes-core/cubes}/admin/cockpit/README.md | 0 .../cubes-core/cubes}/admin/cockpit/deploy.py | 0 .../cubes}/admin/cockpit/manifest.mjs | 4 +- .../cubes}/admin/hostname/deploy.py | 0 .../cubes}/admin/hostname/manifest.mjs | 4 +- .../cubes-core/cubes}/admin/locale/README.md | 4 +- .../cubes-core/cubes}/admin/locale/deploy.py | 0 .../cubes}/admin/locale/manifest.mjs | 4 +- .../cubes}/apt/essentials/README.md | 0 .../cubes}/apt/essentials/deploy.py | 0 .../cubes}/apt/essentials/manifest.mjs | 4 +- .../cubes-core/cubes}/apt/install/README.md | 0 .../cubes-core/cubes}/apt/install/deploy.py | 0 .../cubes}/apt/install/manifest.mjs | 4 +- .../cubes}/armor/fail2ban/README.md | 0 .../cubes}/armor/fail2ban/deploy.py | 0 .../cubes}/armor/fail2ban/jail.local | 0 .../cubes}/armor/fail2ban/manifest.mjs | 4 +- .../cubes-core/cubes}/armor/ssh/README.md | 0 .../cubes-core/cubes}/armor/ssh/deploy.py | 0 .../cubes-core/cubes}/armor/ssh/manifest.mjs | 4 +- .../cubes-core/cubes}/armor/ufw/README.md | 0 .../cubes-core/cubes}/armor/ufw/deploy.py | 0 .../cubes-core/cubes}/armor/ufw/manifest.mjs | 4 +- .../cubes-core/cubes}/caddy/base/README.md | 0 .../cubes-core/cubes}/caddy/base/deploy.py | 0 .../cubes-core/cubes}/caddy/base/manifest.mjs | 4 +- .../cubes-core/cubes}/caddy/spa/README.md | 0 .../cubes-core/cubes}/caddy/spa/deploy.py | 0 .../cubes-core/cubes}/caddy/spa/manifest.mjs | 4 +- .../cubes-core/cubes}/git/clone/deploy.py | 0 .../cubes-core/cubes}/git/clone/manifest.mjs | 4 +- .../cubes}/network/tailscale/README.md | 8 +- .../cubes}/network/tailscale/deploy.py | 0 .../cubes}/network/tailscale/manifest.mjs | 5 +- .../network/wifi/access-point/README.md | 15 +- .../network/wifi/access-point/deploy.py | 0 .../network/wifi/access-point/manifest.mjs | 5 +- .../cubes}/network/wifi/connection/README.md | 5 + .../cubes}/network/wifi/connection/deploy.py | 0 .../network/wifi/connection/manifest.mjs | 5 +- .../cubes}/runtime/docker/README.md | 0 .../cubes}/runtime/docker/deploy.py | 0 .../cubes}/runtime/docker/manifest.mjs | 4 +- .../cubes}/runtime/nodevm/README.md | 0 .../cubes}/runtime/nodevm/deploy.py | 0 .../cubes}/runtime/nodevm/manifest.mjs | 4 +- .../cubes}/service/autostart/README.md | 0 .../cubes}/service/autostart/deploy.py | 0 .../cubes}/service/autostart/manifest.mjs | 4 +- .../cubes-core/cubes}/ssh/authorize/README.md | 0 .../cubes-core/cubes}/ssh/authorize/deploy.py | 0 .../cubes}/ssh/authorize/manifest.mjs | 4 +- .../cubes-core/cubes}/ssh/keygen/README.md | 0 .../cubes-core/cubes}/ssh/keygen/deploy.py | 0 .../cubes-core/cubes}/ssh/keygen/manifest.mjs | 4 +- .../cubes-core/cubes}/ssh/keyman/README.md | 0 .../cubes-core/cubes}/ssh/keyman/deploy.py | 0 .../cubes-core/cubes}/ssh/keyman/manifest.mjs | 4 +- .../cubes-core/cubes}/user/add/README.md | 19 +- .../cubes-core/cubes}/user/add/config.fish | 0 .../cubes-core/cubes}/user/add/deploy.py | 5 +- .../cubes-core/cubes/user/add/manifest.mjs | 28 ++ .../cubes-core/cubes}/user/add/ssh-agent.fish | 0 .../cubes-core/cubes}/user/edit/README.md | 2 +- .../cubes-core/cubes}/user/edit/deploy.py | 0 .../cubes-core/cubes}/user/edit/manifest.mjs | 5 +- packages/cubes-core/package.json | 45 +++ packages/nopy-cube/LICENSE | 21 ++ packages/nopy-cube/README.md | 69 +++++ packages/nopy-cube/package.json | 60 ++++ .../src/cubes => nopy-cube/src}/factories.ts | 2 +- packages/nopy-cube/src/index.ts | 31 +++ .../src/cubes => nopy-cube/src}/types.ts | 24 +- .../src/cubes => nopy-cube/src}/utils.ts | 2 +- .../tests/factories.test.ts} | 4 +- .../nopy-cube/tests/helpers/foreign-zod.ts | 29 ++ .../tests/types.test.ts} | 62 ++++- .../tests/utils.test.ts} | 4 +- packages/nopy-cube/tsconfig.json | 15 + packages/nopy-cube/vitest.config.ts | 25 ++ packages/nopy/README.md | 129 +++++++-- packages/nopy/docs/API.md | 72 ++++- packages/nopy/docs/CUBE-BUNDLES.md | 263 ++++++++++++++++++ packages/nopy/docs/CUBE-PACKAGES.md | 199 ++++++++++--- packages/nopy/docs/REFACTORING.md | 26 ++ packages/nopy/docs/SESSION_FORMAT.md | 16 +- packages/nopy/package.json | 3 +- packages/nopy/src/cubes/dependencies.ts | 69 ++++- packages/nopy/src/cubes/index.ts | 41 +-- packages/nopy/src/cubes/loader.ts | 119 ++++++-- packages/nopy/src/cubes/packages.ts | 108 +++++++ packages/nopy/src/cubes/resolve-hook.mjs | 62 +++++ packages/nopy/src/index.ts | 14 +- packages/nopy/src/nopy.common.ts | 221 ++++++++++++--- packages/nopy/src/nopy.executor.ts | 58 +++- packages/nopy/src/nopy.main.ts | 14 +- packages/nopy/src/nopy.prompts.ts | 39 ++- packages/nopy/src/nopy.session.ts | 49 ---- packages/nopy/tests/common.test.ts | 199 ++++++++++--- packages/nopy/tests/config.loading.test.ts | 47 ++++ .../tests/cubes.dependencies.edge.test.ts | 138 ++++++++- .../nopy/tests/cubes.dependencies.test.ts | 4 +- packages/nopy/tests/cubes.loader.edge.test.ts | 115 ++++++++ packages/nopy/tests/cubes.packages.test.ts | 168 +++++++++++ .../nopy/tests/cubes.resolve-hook.test.ts | 107 +++++++ packages/nopy/tests/executor.test.ts | 69 ++++- packages/nopy/tests/hooks.test.ts | 2 +- packages/nopy/tests/main.test.ts | 8 +- packages/nopy/tests/prompts.test.ts | 62 +++-- packages/nopy/tests/session.test.ts | 55 ---- packages/nopy/tsconfig.json | 2 +- packages/nopy/vitest.config.ts | 11 + pnpm-lock.yaml | 33 +++ scripts/linked-deps.mjs | 51 ++++ scripts/publish-order.mjs | 58 ++++ scripts/verify-pack.mjs | 72 +++++ tsconfig.base.json | 1 + tsconfig.json | 6 +- 130 files changed, 3386 insertions(+), 520 deletions(-) delete mode 100644 cubes/user/add/manifest.mjs create mode 100644 packages/cubes-core/LICENSE create mode 100644 packages/cubes-core/README.md rename {cubes => packages/cubes-core/cubes}/admin/cockpit/README.md (100%) rename {cubes => packages/cubes-core/cubes}/admin/cockpit/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/admin/cockpit/manifest.mjs (54%) rename {cubes => packages/cubes-core/cubes}/admin/hostname/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/admin/hostname/manifest.mjs (83%) rename {cubes => packages/cubes-core/cubes}/admin/locale/README.md (87%) rename {cubes => packages/cubes-core/cubes}/admin/locale/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/admin/locale/manifest.mjs (86%) rename {cubes => packages/cubes-core/cubes}/apt/essentials/README.md (100%) rename {cubes => packages/cubes-core/cubes}/apt/essentials/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/apt/essentials/manifest.mjs (76%) rename {cubes => packages/cubes-core/cubes}/apt/install/README.md (100%) rename {cubes => packages/cubes-core/cubes}/apt/install/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/apt/install/manifest.mjs (82%) rename {cubes => packages/cubes-core/cubes}/armor/fail2ban/README.md (100%) rename {cubes => packages/cubes-core/cubes}/armor/fail2ban/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/armor/fail2ban/jail.local (100%) rename {cubes => packages/cubes-core/cubes}/armor/fail2ban/manifest.mjs (55%) rename {cubes => packages/cubes-core/cubes}/armor/ssh/README.md (100%) rename {cubes => packages/cubes-core/cubes}/armor/ssh/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/armor/ssh/manifest.mjs (85%) rename {cubes => packages/cubes-core/cubes}/armor/ufw/README.md (100%) rename {cubes => packages/cubes-core/cubes}/armor/ufw/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/armor/ufw/manifest.mjs (77%) rename {cubes => packages/cubes-core/cubes}/caddy/base/README.md (100%) rename {cubes => packages/cubes-core/cubes}/caddy/base/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/caddy/base/manifest.mjs (86%) rename {cubes => packages/cubes-core/cubes}/caddy/spa/README.md (100%) rename {cubes => packages/cubes-core/cubes}/caddy/spa/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/caddy/spa/manifest.mjs (80%) rename {cubes => packages/cubes-core/cubes}/git/clone/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/git/clone/manifest.mjs (82%) rename {cubes => packages/cubes-core/cubes}/network/tailscale/README.md (67%) rename {cubes => packages/cubes-core/cubes}/network/tailscale/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/network/tailscale/manifest.mjs (85%) rename {cubes => packages/cubes-core/cubes}/network/wifi/access-point/README.md (90%) rename {cubes => packages/cubes-core/cubes}/network/wifi/access-point/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/network/wifi/access-point/manifest.mjs (86%) rename {cubes => packages/cubes-core/cubes}/network/wifi/connection/README.md (83%) rename {cubes => packages/cubes-core/cubes}/network/wifi/connection/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/network/wifi/connection/manifest.mjs (89%) rename {cubes => packages/cubes-core/cubes}/runtime/docker/README.md (100%) rename {cubes => packages/cubes-core/cubes}/runtime/docker/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/runtime/docker/manifest.mjs (76%) rename {cubes => packages/cubes-core/cubes}/runtime/nodevm/README.md (100%) rename {cubes => packages/cubes-core/cubes}/runtime/nodevm/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/runtime/nodevm/manifest.mjs (89%) rename {cubes => packages/cubes-core/cubes}/service/autostart/README.md (100%) rename {cubes => packages/cubes-core/cubes}/service/autostart/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/service/autostart/manifest.mjs (86%) rename {cubes => packages/cubes-core/cubes}/ssh/authorize/README.md (100%) rename {cubes => packages/cubes-core/cubes}/ssh/authorize/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/ssh/authorize/manifest.mjs (79%) rename {cubes => packages/cubes-core/cubes}/ssh/keygen/README.md (100%) rename {cubes => packages/cubes-core/cubes}/ssh/keygen/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/ssh/keygen/manifest.mjs (89%) rename {cubes => packages/cubes-core/cubes}/ssh/keyman/README.md (100%) rename {cubes => packages/cubes-core/cubes}/ssh/keyman/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/ssh/keyman/manifest.mjs (86%) rename {cubes => packages/cubes-core/cubes}/user/add/README.md (78%) rename {cubes => packages/cubes-core/cubes}/user/add/config.fish (100%) rename {cubes => packages/cubes-core/cubes}/user/add/deploy.py (90%) create mode 100644 packages/cubes-core/cubes/user/add/manifest.mjs rename {cubes => packages/cubes-core/cubes}/user/add/ssh-agent.fish (100%) rename {cubes => packages/cubes-core/cubes}/user/edit/README.md (90%) rename {cubes => packages/cubes-core/cubes}/user/edit/deploy.py (100%) rename {cubes => packages/cubes-core/cubes}/user/edit/manifest.mjs (88%) create mode 100644 packages/cubes-core/package.json create mode 100644 packages/nopy-cube/LICENSE create mode 100644 packages/nopy-cube/README.md create mode 100644 packages/nopy-cube/package.json rename packages/{nopy/src/cubes => nopy-cube/src}/factories.ts (96%) create mode 100644 packages/nopy-cube/src/index.ts rename packages/{nopy/src/cubes => nopy-cube/src}/types.ts (87%) rename packages/{nopy/src/cubes => nopy-cube/src}/utils.ts (98%) rename packages/{nopy/tests/cubes.factories.test.ts => nopy-cube/tests/factories.test.ts} (88%) create mode 100644 packages/nopy-cube/tests/helpers/foreign-zod.ts rename packages/{nopy/tests/cubes.types.test.ts => nopy-cube/tests/types.test.ts} (64%) rename packages/{nopy/tests/cubes.utils.test.ts => nopy-cube/tests/utils.test.ts} (93%) create mode 100644 packages/nopy-cube/tsconfig.json create mode 100644 packages/nopy-cube/vitest.config.ts create mode 100644 packages/nopy/docs/CUBE-BUNDLES.md create mode 100644 packages/nopy/src/cubes/packages.ts create mode 100644 packages/nopy/src/cubes/resolve-hook.mjs create mode 100644 packages/nopy/tests/cubes.packages.test.ts create mode 100644 packages/nopy/tests/cubes.resolve-hook.test.ts create mode 100644 scripts/linked-deps.mjs create mode 100644 scripts/publish-order.mjs create mode 100644 scripts/verify-pack.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0c461f3..e246ba2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -81,6 +81,13 @@ jobs: echo "::endgroup::" done + - name: Verify the packed manifests + # `workspace:*` is mandatory in the manifests but meaningless to npm, so + # a range that survives into a tarball is an install failure for every + # consumer. The publish workflows run this too; running it here is what + # puts the failure on the pull request instead of on the release. + run: node scripts/verify-pack.mjs + - name: Upload coverage reports if: always() continue-on-error: true diff --git a/.gitea/workflows/publish-snapshot.yml b/.gitea/workflows/publish-snapshot.yml index ca03453..234f503 100644 --- a/.gitea/workflows/publish-snapshot.yml +++ b/.gitea/workflows/publish-snapshot.yml @@ -1,5 +1,5 @@ -# Every commit that lands on `main` publishes a prerelease of both packages to -# the Gitea npm registry under the `main` dist-tag: +# Every commit that lands on `main` publishes a prerelease of every publishable +# package to the Gitea npm registry under the `main` dist-tag: # # pnpm add @bitsquare/nopy@main # @@ -78,6 +78,11 @@ jobs: # Explicit, so the publish step can skip lifecycle scripts entirely. run: pnpm run build + - name: Verify the packed manifests + # Packages link to each other with `workspace:*`, which npm cannot + # install. Proves on the tarball that pack rewrote it. + run: node scripts/verify-pack.mjs + - name: Authenticate against the Gitea registry run: | set -euo pipefail @@ -97,23 +102,35 @@ jobs: export npm_config_userconfig="$NPMRC" : "${GITHUB_STEP_SUMMARY:=/dev/null}" short_sha=$(git rev-parse --short=7 HEAD) + # Dependencies first, so the registry never briefly holds a package + # whose dependency has not landed yet. + dirs=$(node scripts/publish-order.mjs) - for dir in packages/*/; do - name=$(node -p "require('./${dir}package.json').name") - base=$(node -p "require('./${dir}package.json').version") + # Pass 1: stamp every manifest before anything is packed. `pnpm + # publish` substitutes `workspace:*` with the version the linked + # package declares at pack time, so nopy-cube has to be carrying its + # snapshot version by the time nopy is packed. + for dir in $dirs; do + base=$(node -p "require('./${dir}/package.json').version") # `g` prefix keeps the identifier a valid semver one even when the # abbreviated sha happens to be all digits. version="${base}-main.${{ github.run_number }}.g${short_sha}" + (cd "$dir" && npm pkg set "version=${version}") + done + + # Pass 2: publish. + for dir in $dirs; do + name=$(node -p "require('./${dir}/package.json').name") + version=$(node -p "require('./${dir}/package.json').version") echo "::group::${name}@${version}" 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" - ) + # pnpm, not npm: npm ships `workspace:*` verbatim and the install + # then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because + # stamping the versions above left the tree dirty. + (cd "$dir" && pnpm publish --ignore-scripts --no-git-checks --tag main --registry "$REGISTRY") fi echo "::endgroup::" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index c056cef..609b003 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,10 +1,15 @@ # Tag-driven release of a single package. # # git tag nopy-v1.2.0 && git push origin nopy-v1.2.0 +# git tag nopy-cube-v1.2.0 && git push origin nopy-cube-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. +# +# Packages that link to each other release dependency-first — `nopy-cube` before +# `nopy` — because the linked version is resolved at pack time. The run refuses +# to publish otherwise. # A version with a prerelease part (1.2.0-rc.1) publishes under `next` instead # of `latest`. # @@ -108,6 +113,31 @@ jobs: - name: Install run: pnpm install --frozen-lockfile + - name: Check the linked workspace packages are already released + env: + NAME: ${{ steps.target.outputs.name }} + DIR: ${{ steps.target.outputs.dir }} + run: | + set -euo pipefail + # `pnpm publish` turns `workspace:*` into the version the linked + # package declares at this commit. If that version is not on the + # registry yet, the release installs to a broken tree — and npmjs + # only lets you unpublish for 72 hours. Release the dependency first: + # nopy-cube, then nopy, then any bundle. + # + # npmjs only: it is the irreversible one, and it needs no credentials + # to read, which this step does not have yet. + missing=0 + for spec in $(node scripts/linked-deps.mjs "$DIR" | tr ' ' '@'); do + if npm view "$spec" version --registry "$NPMJS_REGISTRY" >/dev/null 2>&1; then + echo "${spec} is published" + else + echo "::error::${NAME} depends on ${spec}, which is not on npmjs. Release it first." + missing=1 + fi + done + exit "$missing" + - name: Lint run: pnpm run lint:ci @@ -121,6 +151,11 @@ jobs: # Explicit, so the publish steps can skip lifecycle scripts entirely. run: pnpm run build + - name: Verify the packed manifests + # Packages link to each other with `workspace:*`, which npm cannot + # install. Proves on the tarball that pack rewrote it. + run: node scripts/verify-pack.mjs + - name: Publish to the Gitea registry env: NAME: ${{ steps.target.outputs.name }} @@ -139,7 +174,10 @@ jobs: 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") + # pnpm, not npm: npm ships `workspace:*` verbatim and the install + # then fails with EUNSUPPORTEDPROTOCOL. --no-git-checks because a + # tag build is a detached HEAD. + (cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --registry "$GITEA_REGISTRY") fi - name: Publish to npmjs @@ -162,7 +200,7 @@ jobs: 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") + (cd "$DIR" && pnpm publish --ignore-scripts --no-git-checks --tag "$DIST_TAG" --access public --registry "$NPMJS_REGISTRY") fi - name: Remove the registry credentials diff --git a/.nopyrc.json b/.nopyrc.json index f51048b..a015fff 100644 --- a/.nopyrc.json +++ b/.nopyrc.json @@ -1,6 +1,7 @@ { "hosts": [], - "cubeDirs": ["./cubes"], + "cubeDirs": [], + "cubePackages": ["@bitsquare/cubes-core"], "env": {}, "log": { "verbosity": "info", diff --git a/CLAUDE.md b/CLAUDE.md index 33deed2..2ac56a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,26 +4,32 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this repo is -A pnpm workspace holding two independently published CLIs plus the pyinfra -deployment units one of them runs: +A pnpm workspace holding two independently published CLIs, the authoring package +their deployment units are written against, and one bundle of those units: -| Path | Package | Binary | Role | -| ----------------- | ------------------- | -------- | -------------------------------------------------------- | -| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution | -| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management, shelling out to `age` / `ssh-keygen` | -| `cubes/` | — | — | the deployment units `nopy` runs (not published) | +| Path | Package | Binary | Role | +| --------------------- | ---------------------- | -------- | -------------------------------------------------------- | +| `packages/nopy` | `@bitsquare/nopy` | `nopy` | interactive pyinfra script management and execution | +| `packages/keyman` | `@bitsquare/keyman` | `keyman` | SSH key management, shelling out to `age` / `ssh-keygen` | +| `packages/nopy-cube` | `@bitsquare/nopy-cube` | — | the authoring surface a `manifest.mjs` imports | +| `packages/cubes-core` | `@bitsquare/cubes-core`| — | the core cube bundle (22 cubes), no TypeScript | -The root package is private; only `packages/*` ship. The two packages do not -depend on each other. +The root package is private; everything under `packages/` ships. `keyman` stands +alone, but `nopy` and `cubes-core` both depend on `nopy-cube` (`workspace:*`), so +publish order matters — see *Releasing*. + +`cubes-core` is consumed the way a third party would consume it: the root +`.nopyrc.json` names it in `cubePackages`, and the loader reads it out of +`node_modules`. There is no `cubes/` directory at the repo root any more. ## Commands ```sh pnpm install # also installs the git hooks via simple-git-hooks -pnpm run build # tsc --build across both packages (project references) -pnpm run typecheck # tsc --build --noEmit +pnpm run build # tsc --build across the TS packages (project references) +pnpm run typecheck # tsc --build (see below — it really does emit) pnpm run lint # biome check . (lint:fix / lint:ci variants) -pnpm test # vitest run, both packages +pnpm test # vitest run, every package with tests pnpm run test:coverage # vitest with the coverage gate pnpm run coverage:summary # renders the last coverage run as a Markdown table ``` @@ -41,6 +47,12 @@ pnpm --filter @bitsquare/keyman run keyman `typescript` is the 7.x native compiler, so `tsc` *is* the fast one — there is no separate `tsgo` binary. +`typecheck` is plain `tsc --build`, not `--noEmit`. Once a project has +`references`, `--noEmit` is rejected outright (TS6310: *referenced project may +not disable emit*) — a composite project has to emit the declarations its +dependents read. So the typecheck writes `dist` as a side effect; it is +gitignored, and the upside is that the gate now also proves the build works. + ## Verification gate `lint:ci` → `typecheck` → `test:coverage` is one gate, run in three places: the @@ -55,7 +67,14 @@ locally and on the runner. Barrel files (`src/index.ts`, `src/cubes/index.ts`, `src/nopy.cubes.ts`) and the Commander argv wiring (`src/*.cli.ts`) are excluded; adding logic to those files means moving it somewhere covered. -Both packages set `pool: 'forks'` because tests use `process.chdir()` — most +nopy's vitest config aliases `@bitsquare/nopy-cube` to that package's **source**, +not to the workspace link (which points at a `dist` that only exists after a +build), so the gate does not depend on build ordering and can never run against a +stale artefact. The same config excludes `**/nopy-cube/**` from coverage — without +it nopy's numbers absorb another package's files. `cubes-core` has no tests of its +own; the loader tests in nopy cover the contract it implements. + +The three TS packages set `pool: 'forks'` because tests use `process.chdir()` — most loader/config tests build a throwaway tree under `os.tmpdir()` and chdir into it, since discovery is driven entirely by the working directory. @@ -68,22 +87,43 @@ One pass per invocation, `nopy.main.ts` orchestrating: Per-property strategy comes from the child's `resolution` block (`merge` is the default: arrays concatenate and dedupe, objects deep-merge; `override` replaces). Only properties listed in `PATH_PROPERTIES` (`cubeDirs`) get - relative paths resolved against their own config file's directory. **Throws** + relative paths resolved against their own config file's directory; + `cubePackages` needs the same origin for a different reason, so each entry is + normalised into a `CubePackageRef {spec, from}` — `from` is the directory of + the config that named it, which is where the package gets resolved from. + **Throws** if no config file exists anywhere — which is why `nopy.cli.ts` calls it lazily inside the action, so `--help`/`--version` work outside a project. -2. **`cubes/loader.ts`** — `findCubeDirectories()` unions `config.cubeDirs` with - every ancestor directory holding a `.npcubes` marker, then scans each - recursively (skipping dotted dirs and `node_modules`). A directory is a cube +2. **`cubes/packages.ts`** — `resolveCubePackages()` turns each `CubePackageRef` + into a package root plus the directories its `nopy.cubes` field declares. + Resolution goes through `createRequire(...).resolve.paths()` + `existsSync`, + deliberately bypassing the `exports` map: a bundle ships directories and has + no entry point to declare. `existsSync` also follows the symlink pnpm plants + at `node_modules/`, which a `readdir` scan skips outright (it reports + `isSymbolicLink()`, not `isDirectory()`). A missing package, an unreadable + manifest, a missing `nopy.cubes`, a directory that does not exist, and an + entry pointing outside the package root are all errors, never silent skips. + Duplicate refs are deduped here, last-wins, because `mergeValue` only dedupes + arrays of primitives and these are objects. +3. **`cubes/loader.ts`** — `findCubeRoots()` unions `config.cubeDirs`, the + directories from `cubePackages`, and every ancestor directory holding a + `.npcubes` marker, then scans each recursively (skipping dotted dirs and + `node_modules`). A directory is a cube when it holds both a manifest (`manifest.mjs` or `*.manifest.mjs`) and a deploy script (`deploy.py` or `*.deploy.py`); manifests are loaded by dynamic `import()`. Cube id = `manifest.id` → a `[id]` prefix in `manifest.name` → the directory basename. Ids are flat and need not mirror the path - (`cubes/network/tailscale` declares `net:tailscale`). Duplicate ids and bad - manifests become entries in `errors`, which aborts the run. -3. **`nopy.workflow.ts`** — picks interactive, file-replay, or history-replay and + (`cubes/network/tailscale` declares `net:tailscale`), and they are claimed + **globally**, not per source: a duplicate is a hard error naming every + claimant, with no precedence rule and no shadowing. Each cube carries a + `source` — `{type: 'dir', dir}` or `{type: 'package', packageName, dir}` — + which is what makes that error legible when the collision is between a local + tree and an installed bundle. Duplicate ids and bad manifests become entries + in `errors`, which aborts the run. +4. **`nopy.workflow.ts`** — picks interactive, file-replay, or history-replay and normalises all three into a `WorkflowResult`. Replays never re-prompt except for passwords (never persisted) and a missing host. -4. **`cubes/dependencies.ts` → `BuildContext.resolveCube()`** — the core. +5. **`cubes/dependencies.ts` → `BuildContext.resolveCube()`** — the core. Recursive, per (cube, host): assign params and schema defaults → collect variables (prompt, or read them back from the session on replay) → run `before` hooks → resolve `manifest.dependencies(vars)` (dynamic: it receives @@ -92,33 +132,71 @@ One pass per invocation, `nopy.main.ts` orchestrating: `${cubeId}:${host}` set makes emission idempotent. Hooks get a `HookContext` whose `exec(id, vars)` re-enters `resolveCube`, so a hook can pull in a cube that is not a declared dependency. -5. **`nopy.executor.ts`** — runs the built `pyinfra -y --data K=V ... --chdir