From 736c01216ac43837502f573767cd947a04720393 Mon Sep 17 00:00:00 2001 From: Benjamin Diedrichsen Date: Mon, 27 Jul 2026 13:09:00 +0200 Subject: [PATCH] initial transfer --- .gitignore | 149 +- .nopyrc.json | 9 + .serena/.gitignore | 2 + .serena/project.yml | 133 + Vagrantfile | 22 + biome.json | 42 + cubes/admin/cockpit/README.md | 76 + cubes/admin/cockpit/deploy.py | 13 + cubes/admin/cockpit/manifest.mjs | 8 + cubes/admin/hostname/deploy.py | 34 + cubes/admin/hostname/manifest.mjs | 16 + cubes/admin/locale/README.md | 23 + cubes/admin/locale/deploy.py | 55 + cubes/admin/locale/manifest.mjs | 14 + cubes/apt/essentials/README.md | 39 + cubes/apt/essentials/deploy.py | 18 + cubes/apt/essentials/manifest.mjs | 11 + cubes/apt/install/README.md | 62 + cubes/apt/install/deploy.py | 31 + cubes/apt/install/manifest.mjs | 12 + cubes/armor/fail2ban/README.md | 41 + cubes/armor/fail2ban/deploy.py | 18 + cubes/armor/fail2ban/jail.local | 8 + cubes/armor/fail2ban/manifest.mjs | 7 + cubes/armor/ssh/README.md | 83 + cubes/armor/ssh/deploy.py | 43 + cubes/armor/ssh/manifest.mjs | 18 + cubes/armor/ufw/README.md | 77 + cubes/armor/ufw/deploy.py | 13 + cubes/armor/ufw/manifest.mjs | 11 + cubes/caddy/base/README.md | 120 + cubes/caddy/base/deploy.py | 34 + cubes/caddy/base/manifest.mjs | 19 + cubes/caddy/spa/README.md | 137 + cubes/caddy/spa/deploy.py | 32 + cubes/caddy/spa/manifest.mjs | 12 + cubes/git/clone/deploy.py | 18 + cubes/git/clone/manifest.mjs | 13 + cubes/network/tailscale/README.md | 27 + cubes/network/tailscale/deploy.py | 45 + cubes/network/tailscale/manifest.mjs | 17 + cubes/network/wifi/access-point/README.md | 234 ++ cubes/network/wifi/access-point/deploy.py | 70 + cubes/network/wifi/access-point/manifest.mjs | 20 + cubes/network/wifi/connection/README.md | 54 + cubes/network/wifi/connection/deploy.py | 43 + cubes/network/wifi/connection/manifest.mjs | 19 + cubes/runtime/docker/README.md | 60 + cubes/runtime/docker/deploy.py | 75 + cubes/runtime/docker/manifest.mjs | 11 + cubes/runtime/nodevm/README.md | 83 + cubes/runtime/nodevm/deploy.py | 58 + cubes/runtime/nodevm/manifest.mjs | 20 + cubes/service/autostart/README.md | 176 ++ cubes/service/autostart/deploy.py | 27 + cubes/service/autostart/manifest.mjs | 13 + cubes/ssh/authorize/README.md | 32 + cubes/ssh/authorize/deploy.py | 13 + cubes/ssh/authorize/manifest.mjs | 12 + cubes/ssh/keygen/README.md | 93 + cubes/ssh/keygen/deploy.py | 46 + cubes/ssh/keygen/manifest.mjs | 20 + cubes/ssh/keyman/README.md | 110 + cubes/ssh/keyman/deploy.py | 96 + cubes/ssh/keyman/manifest.mjs | 19 + cubes/user/add/README.md | 130 + cubes/user/add/config.fish | 10 + cubes/user/add/deploy.py | 90 + cubes/user/add/manifest.mjs | 25 + cubes/user/add/ssh-agent.fish | 14 + cubes/user/edit/README.md | 52 + cubes/user/edit/deploy.py | 24 + cubes/user/edit/manifest.mjs | 20 + package-lock.json | 2340 ++++++++++++++++ package.json | 41 + packages/keyman/README.md | 152 ++ packages/keyman/dist/index.d.ts | 3 + packages/keyman/dist/index.js | 3 + packages/keyman/dist/keyman.cli.d.ts | 2 + packages/keyman/dist/keyman.cli.js | 11 + packages/keyman/dist/keyman.config.d.ts | 76 + packages/keyman/dist/keyman.config.js | 212 ++ packages/keyman/dist/keyman.copy.d.ts | 1 + packages/keyman/dist/keyman.copy.js | 50 + packages/keyman/dist/keyman.decrypt.d.ts | 1 + packages/keyman/dist/keyman.decrypt.js | 45 + packages/keyman/dist/keyman.encrypt.d.ts | 1 + packages/keyman/dist/keyman.encrypt.js | 37 + packages/keyman/dist/keyman.generate.d.ts | 1 + packages/keyman/dist/keyman.generate.js | 65 + packages/keyman/dist/keyman.list.d.ts | 1 + packages/keyman/dist/keyman.list.js | 115 + packages/keyman/dist/keyman.main.d.ts | 1 + packages/keyman/dist/keyman.main.js | 79 + packages/keyman/dist/keyman.utils.d.ts | 6 + packages/keyman/dist/keyman.utils.js | 21 + packages/keyman/package.json | 29 + packages/keyman/src/index.ts | 3 + packages/keyman/src/keyman.cli.ts | 15 + packages/keyman/src/keyman.config.ts | 267 ++ packages/keyman/src/keyman.copy.ts | 58 + packages/keyman/src/keyman.decrypt.ts | 53 + packages/keyman/src/keyman.encrypt.ts | 49 + packages/keyman/src/keyman.generate.ts | 77 + packages/keyman/src/keyman.list.ts | 139 + packages/keyman/src/keyman.main.ts | 93 + packages/keyman/src/keyman.utils.ts | 23 + packages/keyman/tsconfig.json | 15 + packages/nopy/.nopyrc.json | 11 + packages/nopy/NOPY.REFACTORING.md | 41 + packages/nopy/README.DOCKER.md | 82 + packages/nopy/README.VAGRANT.md | 21 + packages/nopy/README.md | 380 +++ packages/nopy/Ubuntu-24LTS.Dockerfile | 33 + packages/nopy/cubes/apt/all/deploy.py | 4 + packages/nopy/cubes/apt/all/manifest.mjs | 7 + packages/nopy/cubes/apt/essentials/deploy.py | 16 + .../nopy/cubes/apt/essentials/manifest.mjs | 10 + packages/nopy/cubes/apt/more/deploy.py | 16 + packages/nopy/cubes/apt/more/manifest.mjs | 7 + packages/nopy/dist/cubes/dependencies.d.ts | 46 + packages/nopy/dist/cubes/dependencies.js | 114 + packages/nopy/dist/cubes/factories.d.ts | 21 + packages/nopy/dist/cubes/factories.js | 23 + packages/nopy/dist/cubes/index.d.ts | 13 + packages/nopy/dist/cubes/index.js | 17 + packages/nopy/dist/cubes/loader.d.ts | 22 + packages/nopy/dist/cubes/loader.js | 102 + packages/nopy/dist/cubes/types.d.ts | 74 + packages/nopy/dist/cubes/types.js | 57 + packages/nopy/dist/cubes/utils.d.ts | 20 + packages/nopy/dist/cubes/utils.js | 33 + packages/nopy/dist/index.d.ts | 20 + packages/nopy/dist/index.js | 23 + packages/nopy/dist/nopy.cli.d.ts | 6 + packages/nopy/dist/nopy.cli.js | 127 + packages/nopy/dist/nopy.common.d.ts | 20 + packages/nopy/dist/nopy.common.js | 32 + packages/nopy/dist/nopy.config.d.ts | 114 + packages/nopy/dist/nopy.config.js | 263 ++ packages/nopy/dist/nopy.cubes.d.ts | 22 + packages/nopy/dist/nopy.cubes.js | 16 + packages/nopy/dist/nopy.executor.d.ts | 92 + packages/nopy/dist/nopy.executor.js | 138 + packages/nopy/dist/nopy.history.d.ts | 90 + packages/nopy/dist/nopy.history.js | 170 ++ packages/nopy/dist/nopy.main.d.ts | 38 + packages/nopy/dist/nopy.main.js | 163 ++ packages/nopy/dist/nopy.prompts.d.ts | 21 + packages/nopy/dist/nopy.prompts.js | 174 ++ packages/nopy/dist/nopy.session.d.ts | 112 + packages/nopy/dist/nopy.session.js | 166 ++ packages/nopy/dist/nopy.workflow.d.ts | 49 + packages/nopy/dist/nopy.workflow.js | 149 ++ packages/nopy/docs/API.md | 655 +++++ packages/nopy/docs/HOOKS.md | 90 + packages/nopy/docs/SESSION_FORMAT.md | 323 +++ packages/nopy/example.nopysession.json | 18 + packages/nopy/package.json | 44 + packages/nopy/src/cubes/dependencies.ts | 140 + packages/nopy/src/cubes/factories.ts | 30 + packages/nopy/src/cubes/index.ts | 42 + packages/nopy/src/cubes/loader.ts | 124 + packages/nopy/src/cubes/types.ts | 116 + packages/nopy/src/cubes/utils.ts | 38 + packages/nopy/src/index.ts | 86 + packages/nopy/src/nopy.cli.ts | 151 ++ packages/nopy/src/nopy.common.ts | 41 + packages/nopy/src/nopy.config.ts | 378 +++ packages/nopy/src/nopy.cubes.ts | 29 + packages/nopy/src/nopy.executor.ts | 222 ++ packages/nopy/src/nopy.history.ts | 224 ++ packages/nopy/src/nopy.main.ts | 228 ++ packages/nopy/src/nopy.prompts.ts | 204 ++ packages/nopy/src/nopy.session.ts | 246 ++ packages/nopy/src/nopy.workflow.ts | 220 ++ packages/nopy/tests/config.test.ts | 55 + .../nopy/tests/cubes.dependencies.test.ts | 115 + packages/nopy/tests/cubes.factories.test.ts | 39 + packages/nopy/tests/cubes.loader.test.ts | 138 + packages/nopy/tests/cubes.utils.test.ts | 44 + packages/nopy/tests/executor.test.ts | 169 ++ packages/nopy/tests/history.test.ts | 255 ++ packages/nopy/tests/hooks.test.ts | 69 + packages/nopy/tests/session.test.ts | 212 ++ packages/nopy/tsconfig.json | 15 + packages/nopy/vitest.config.ts | 16 + pnpm-lock.yaml | 2352 +++++++++++++++++ pnpm-workspace.yaml | 6 + tsconfig.base.json | 25 + tsconfig.json | 8 + 191 files changed, 17622 insertions(+), 136 deletions(-) create mode 100644 .nopyrc.json create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 Vagrantfile create mode 100644 biome.json create mode 100644 cubes/admin/cockpit/README.md create mode 100644 cubes/admin/cockpit/deploy.py create mode 100644 cubes/admin/cockpit/manifest.mjs create mode 100644 cubes/admin/hostname/deploy.py create mode 100644 cubes/admin/hostname/manifest.mjs create mode 100644 cubes/admin/locale/README.md create mode 100644 cubes/admin/locale/deploy.py create mode 100644 cubes/admin/locale/manifest.mjs create mode 100644 cubes/apt/essentials/README.md create mode 100644 cubes/apt/essentials/deploy.py create mode 100644 cubes/apt/essentials/manifest.mjs create mode 100644 cubes/apt/install/README.md create mode 100644 cubes/apt/install/deploy.py create mode 100644 cubes/apt/install/manifest.mjs create mode 100644 cubes/armor/fail2ban/README.md create mode 100644 cubes/armor/fail2ban/deploy.py create mode 100644 cubes/armor/fail2ban/jail.local create mode 100644 cubes/armor/fail2ban/manifest.mjs create mode 100644 cubes/armor/ssh/README.md create mode 100644 cubes/armor/ssh/deploy.py create mode 100644 cubes/armor/ssh/manifest.mjs create mode 100644 cubes/armor/ufw/README.md create mode 100644 cubes/armor/ufw/deploy.py create mode 100644 cubes/armor/ufw/manifest.mjs create mode 100644 cubes/caddy/base/README.md create mode 100644 cubes/caddy/base/deploy.py create mode 100644 cubes/caddy/base/manifest.mjs create mode 100644 cubes/caddy/spa/README.md create mode 100644 cubes/caddy/spa/deploy.py create mode 100644 cubes/caddy/spa/manifest.mjs create mode 100644 cubes/git/clone/deploy.py create mode 100644 cubes/git/clone/manifest.mjs create mode 100644 cubes/network/tailscale/README.md create mode 100644 cubes/network/tailscale/deploy.py create mode 100644 cubes/network/tailscale/manifest.mjs create mode 100644 cubes/network/wifi/access-point/README.md create mode 100644 cubes/network/wifi/access-point/deploy.py create mode 100644 cubes/network/wifi/access-point/manifest.mjs create mode 100644 cubes/network/wifi/connection/README.md create mode 100644 cubes/network/wifi/connection/deploy.py create mode 100644 cubes/network/wifi/connection/manifest.mjs create mode 100644 cubes/runtime/docker/README.md create mode 100644 cubes/runtime/docker/deploy.py create mode 100644 cubes/runtime/docker/manifest.mjs create mode 100644 cubes/runtime/nodevm/README.md create mode 100644 cubes/runtime/nodevm/deploy.py create mode 100644 cubes/runtime/nodevm/manifest.mjs create mode 100644 cubes/service/autostart/README.md create mode 100644 cubes/service/autostart/deploy.py create mode 100644 cubes/service/autostart/manifest.mjs create mode 100644 cubes/ssh/authorize/README.md create mode 100644 cubes/ssh/authorize/deploy.py create mode 100644 cubes/ssh/authorize/manifest.mjs create mode 100644 cubes/ssh/keygen/README.md create mode 100644 cubes/ssh/keygen/deploy.py create mode 100644 cubes/ssh/keygen/manifest.mjs create mode 100644 cubes/ssh/keyman/README.md create mode 100644 cubes/ssh/keyman/deploy.py create mode 100644 cubes/ssh/keyman/manifest.mjs create mode 100644 cubes/user/add/README.md create mode 100644 cubes/user/add/config.fish create mode 100644 cubes/user/add/deploy.py create mode 100644 cubes/user/add/manifest.mjs create mode 100644 cubes/user/add/ssh-agent.fish create mode 100644 cubes/user/edit/README.md create mode 100644 cubes/user/edit/deploy.py create mode 100644 cubes/user/edit/manifest.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/keyman/README.md create mode 100644 packages/keyman/dist/index.d.ts create mode 100644 packages/keyman/dist/index.js create mode 100644 packages/keyman/dist/keyman.cli.d.ts create mode 100755 packages/keyman/dist/keyman.cli.js create mode 100644 packages/keyman/dist/keyman.config.d.ts create mode 100644 packages/keyman/dist/keyman.config.js create mode 100644 packages/keyman/dist/keyman.copy.d.ts create mode 100644 packages/keyman/dist/keyman.copy.js create mode 100644 packages/keyman/dist/keyman.decrypt.d.ts create mode 100644 packages/keyman/dist/keyman.decrypt.js create mode 100644 packages/keyman/dist/keyman.encrypt.d.ts create mode 100644 packages/keyman/dist/keyman.encrypt.js create mode 100644 packages/keyman/dist/keyman.generate.d.ts create mode 100644 packages/keyman/dist/keyman.generate.js create mode 100644 packages/keyman/dist/keyman.list.d.ts create mode 100644 packages/keyman/dist/keyman.list.js create mode 100644 packages/keyman/dist/keyman.main.d.ts create mode 100644 packages/keyman/dist/keyman.main.js create mode 100644 packages/keyman/dist/keyman.utils.d.ts create mode 100644 packages/keyman/dist/keyman.utils.js create mode 100644 packages/keyman/package.json create mode 100644 packages/keyman/src/index.ts create mode 100644 packages/keyman/src/keyman.cli.ts create mode 100644 packages/keyman/src/keyman.config.ts create mode 100644 packages/keyman/src/keyman.copy.ts create mode 100644 packages/keyman/src/keyman.decrypt.ts create mode 100644 packages/keyman/src/keyman.encrypt.ts create mode 100644 packages/keyman/src/keyman.generate.ts create mode 100644 packages/keyman/src/keyman.list.ts create mode 100644 packages/keyman/src/keyman.main.ts create mode 100644 packages/keyman/src/keyman.utils.ts create mode 100644 packages/keyman/tsconfig.json create mode 100644 packages/nopy/.nopyrc.json create mode 100644 packages/nopy/NOPY.REFACTORING.md create mode 100644 packages/nopy/README.DOCKER.md create mode 100644 packages/nopy/README.VAGRANT.md create mode 100644 packages/nopy/README.md create mode 100644 packages/nopy/Ubuntu-24LTS.Dockerfile create mode 100644 packages/nopy/cubes/apt/all/deploy.py create mode 100644 packages/nopy/cubes/apt/all/manifest.mjs create mode 100644 packages/nopy/cubes/apt/essentials/deploy.py create mode 100644 packages/nopy/cubes/apt/essentials/manifest.mjs create mode 100644 packages/nopy/cubes/apt/more/deploy.py create mode 100644 packages/nopy/cubes/apt/more/manifest.mjs create mode 100644 packages/nopy/dist/cubes/dependencies.d.ts create mode 100644 packages/nopy/dist/cubes/dependencies.js create mode 100644 packages/nopy/dist/cubes/factories.d.ts create mode 100644 packages/nopy/dist/cubes/factories.js create mode 100644 packages/nopy/dist/cubes/index.d.ts create mode 100644 packages/nopy/dist/cubes/index.js create mode 100644 packages/nopy/dist/cubes/loader.d.ts create mode 100644 packages/nopy/dist/cubes/loader.js create mode 100644 packages/nopy/dist/cubes/types.d.ts create mode 100644 packages/nopy/dist/cubes/types.js create mode 100644 packages/nopy/dist/cubes/utils.d.ts create mode 100644 packages/nopy/dist/cubes/utils.js create mode 100644 packages/nopy/dist/index.d.ts create mode 100644 packages/nopy/dist/index.js create mode 100644 packages/nopy/dist/nopy.cli.d.ts create mode 100755 packages/nopy/dist/nopy.cli.js create mode 100644 packages/nopy/dist/nopy.common.d.ts create mode 100644 packages/nopy/dist/nopy.common.js create mode 100644 packages/nopy/dist/nopy.config.d.ts create mode 100644 packages/nopy/dist/nopy.config.js create mode 100644 packages/nopy/dist/nopy.cubes.d.ts create mode 100644 packages/nopy/dist/nopy.cubes.js create mode 100644 packages/nopy/dist/nopy.executor.d.ts create mode 100644 packages/nopy/dist/nopy.executor.js create mode 100644 packages/nopy/dist/nopy.history.d.ts create mode 100644 packages/nopy/dist/nopy.history.js create mode 100644 packages/nopy/dist/nopy.main.d.ts create mode 100644 packages/nopy/dist/nopy.main.js create mode 100644 packages/nopy/dist/nopy.prompts.d.ts create mode 100644 packages/nopy/dist/nopy.prompts.js create mode 100644 packages/nopy/dist/nopy.session.d.ts create mode 100644 packages/nopy/dist/nopy.session.js create mode 100644 packages/nopy/dist/nopy.workflow.d.ts create mode 100644 packages/nopy/dist/nopy.workflow.js create mode 100644 packages/nopy/docs/API.md create mode 100644 packages/nopy/docs/HOOKS.md create mode 100644 packages/nopy/docs/SESSION_FORMAT.md create mode 100644 packages/nopy/example.nopysession.json create mode 100644 packages/nopy/package.json create mode 100644 packages/nopy/src/cubes/dependencies.ts create mode 100644 packages/nopy/src/cubes/factories.ts create mode 100644 packages/nopy/src/cubes/index.ts create mode 100644 packages/nopy/src/cubes/loader.ts create mode 100644 packages/nopy/src/cubes/types.ts create mode 100644 packages/nopy/src/cubes/utils.ts create mode 100644 packages/nopy/src/index.ts create mode 100644 packages/nopy/src/nopy.cli.ts create mode 100644 packages/nopy/src/nopy.common.ts create mode 100644 packages/nopy/src/nopy.config.ts create mode 100644 packages/nopy/src/nopy.cubes.ts create mode 100644 packages/nopy/src/nopy.executor.ts create mode 100644 packages/nopy/src/nopy.history.ts create mode 100644 packages/nopy/src/nopy.main.ts create mode 100644 packages/nopy/src/nopy.prompts.ts create mode 100644 packages/nopy/src/nopy.session.ts create mode 100644 packages/nopy/src/nopy.workflow.ts create mode 100644 packages/nopy/tests/config.test.ts create mode 100644 packages/nopy/tests/cubes.dependencies.test.ts create mode 100644 packages/nopy/tests/cubes.factories.test.ts create mode 100644 packages/nopy/tests/cubes.loader.test.ts create mode 100644 packages/nopy/tests/cubes.utils.test.ts create mode 100644 packages/nopy/tests/executor.test.ts create mode 100644 packages/nopy/tests/history.test.ts create mode 100644 packages/nopy/tests/hooks.test.ts create mode 100644 packages/nopy/tests/session.test.ts create mode 100644 packages/nopy/tsconfig.json create mode 100644 packages/nopy/vitest.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 2309cc8..a2b5508 100644 --- a/.gitignore +++ b/.gitignore @@ -1,138 +1,15 @@ -# ---> Node -# Logs -logs +.vault +.vagrant +.python-version + +node_modules +cache +vault/tmp +age.key + +tsconfig.tsbuildinfo *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# vitepress build output -**/.vitepress/dist - -# vitepress cache directory -**/.vitepress/cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* +.nopy.history.json +*.img +*.img.gz diff --git a/.nopyrc.json b/.nopyrc.json new file mode 100644 index 0000000..f51048b --- /dev/null +++ b/.nopyrc.json @@ -0,0 +1,9 @@ +{ + "hosts": [], + "cubeDirs": ["./cubes"], + "env": {}, + "log": { + "verbosity": "info", + "debug": false + } +} diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..0ae80c0 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,133 @@ +# the name by which the project can be referenced within Serena +project_name: "ansiblingz" + + +# list of languages for which language servers are started; choose from: +# al angular ansible bash clojure +# cpp cpp_ccls crystal csharp csharp_omnisharp +# dart elixir elm erlang fortran +# fsharp go groovy haskell haxe +# hlsl html java json julia +# kotlin lean4 lua luau markdown +# matlab msl nix ocaml pascal +# perl php php_phpactor powershell python +# python_jedi python_ty r rego ruby +# ruby_solargraph rust scala scss solidity +# svelte swift systemverilog terraform toml +# typescript typescript_vts vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root) +# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm) +# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three) +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- typescript + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} + +# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos). +# Paths can be absolute or relative to the project root. +# Each folder is registered as an LSP workspace folder, enabling language servers to discover +# symbols and references across package boundaries. +# Currently supported for: TypeScript. +# Example: +# additional_workspace_folders: +# - ../sibling-package +# - ../shared-lib +additional_workspace_folders: [] + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html +fixed_tools: [] + +# list of mode names that are to be activated by default, overriding the setting in the global configuration. +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply +# for this project. +# This setting can, in turn, be overridden by CLI parameters (--mode). +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +default_modes: + +# list of mode names to be activated additionally for this project, e.g. ["query-projects"] +# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes. +# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes +added_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..154fc57 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,22 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +Vagrant.configure("2") do |config| + config.vm.provider "vmware_desktop" do |vmware| + vmware.gui = false + vmware.allowlist_verified = true + end + config.vm.box = "bento/ubuntu-24.04" # Use Ubuntu 24.04 box + config.ssh.insert_key = false + config.vm.box_check_update = false + config.vm.hostname = "ansiblingsvm" + config.vm.network "forwarded_port", guest: 3567, host: 3567, auto_correct: true + config.vm.network "forwarded_port", guest: 80, host: 80, auto_correct: false + config.vm.network "forwarded_port", guest: 443, host: 443, auto_correct: false + # Configure SSH with public key authentication + #config.vm.provision "shell", inline: <<-SHELL + # mkdir -p ~/.ssh + # echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan" >> ~/.ssh/authorized_keys + # chmod 600 ~/.ssh/authorized_keys + #SHELL + end \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5992bab --- /dev/null +++ b/biome.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "ignore": ["dist", "node_modules", ".yarn", "*.lock"] + }, + "organizeImports": { + "enabled": true + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noForEach": "off" + }, + "suspicious": { + "noExplicitAny": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "trailingCommas": "es5" + } + } +} diff --git a/cubes/admin/cockpit/README.md b/cubes/admin/cockpit/README.md new file mode 100644 index 0000000..9ed99e7 --- /dev/null +++ b/cubes/admin/cockpit/README.md @@ -0,0 +1,76 @@ +# cockpit + +**Install Cockpit web-based server management interface** + +## Purpose + +This cube installs Cockpit, a powerful web-based interface for managing Linux servers, making system administration accessible through your browser. + +## What is Cockpit? + +Cockpit is a modern, interactive server admin interface that runs in your web browser. It provides: + +- **Real-time monitoring**: CPU, memory, disk, and network usage graphs +- **Container management**: View and manage Docker containers +- **Service management**: Start, stop, and manage systemd services +- **Storage administration**: Manage disks, RAID, and filesystems +- **Network configuration**: Configure network interfaces and firewall +- **Terminal access**: Built-in terminal for command-line access +- **User management**: Create and manage user accounts +- **Software updates**: View and apply system updates + +Think of it as a control panel for your Linux server - all accessible from any web browser. + +## What This Cube Does + +1. Installs the `cockpit` package +2. Installs `sscg` (Simple Signed Certificate Generator) for HTTPS support +3. Starts the Cockpit service +4. Makes Cockpit accessible on port 9090 + +## Configuration + +This cube currently has no configurable parameters. + +## Dependencies + +None - this cube can run standalone. + +## Post-Installation + +Access Cockpit by navigating to: +``` +https://your-server-ip:9090 +``` + +Login with any valid system user account (e.g., root or a user created with the `user-add` cube). + +### Security Notes + +- Cockpit uses HTTPS by default (self-signed certificate) +- Your browser will show a security warning on first access (expected with self-signed certs) +- Consider using UFW to restrict access: `sudo ufw allow from YOUR_IP to any port 9090` +- Disable Cockpit when not in use: `sudo systemctl stop cockpit.socket` + +## Common Use Cases + +- Monitor server performance in real-time +- Manage Docker containers without command-line +- View system logs and troubleshoot issues +- Configure network settings +- Apply system updates +- Manage storage and filesystems + +## Managing Cockpit + +Start/stop Cockpit: +```bash +sudo systemctl start cockpit.socket +sudo systemctl stop cockpit.socket +sudo systemctl status cockpit.socket +``` + +Disable Cockpit from starting on boot: +```bash +sudo systemctl disable cockpit.socket +``` \ No newline at end of file diff --git a/cubes/admin/cockpit/deploy.py b/cubes/admin/cockpit/deploy.py new file mode 100644 index 0000000..b483a44 --- /dev/null +++ b/cubes/admin/cockpit/deploy.py @@ -0,0 +1,13 @@ +from pyinfra import host +from pyinfra.operations import server, apt + +apt.packages( + packages=[ "sscg cockpit"], + present=True, + _sudo=True +) + +server.service( + 'cockpit', + running=True, +) \ No newline at end of file diff --git a/cubes/admin/cockpit/manifest.mjs b/cubes/admin/cockpit/manifest.mjs new file mode 100644 index 0000000..0d386d5 --- /dev/null +++ b/cubes/admin/cockpit/manifest.mjs @@ -0,0 +1,8 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'admin:cockpit', + name: 'Install cockpit and utils', + dependencies: () => [], +}); diff --git a/cubes/admin/hostname/deploy.py b/cubes/admin/hostname/deploy.py new file mode 100644 index 0000000..a8f2013 --- /dev/null +++ b/cubes/admin/hostname/deploy.py @@ -0,0 +1,34 @@ +from pyinfra import host +from pyinfra.operations import server, files + +""" +Deployment script for the admin:hostname cube. +Uses pyinfra's server.hostname operation to set and persist the system hostname. +""" + +HOSTNAME = host.data.HOSTNAME + +if HOSTNAME: + server.hostname( + name=f"Set system hostname to {HOSTNAME}", + hostname=HOSTNAME, + _sudo=True, + ) + + # 2. Update /etc/hosts to prevent "unable to resolve host" errors + # This looks for the line starting with 127.0.1.1 and replaces it entirely + files.line( + name="Update /etc/hosts for local resolution", + path="/etc/hosts", + line=r"^127\.0\.1\.1\s+.*", + replace=f"127.0.1.1 {HOSTNAME}", + _sudo=True, + ) + # 3. Restart Avahi (Network Broadcast) + # This pushes the name change out to the shared network + server.service( + name="Restart Avahi to broadcast new mDNS name", + service="avahi-daemon", + restarted=True, + _sudo=True, + ) diff --git a/cubes/admin/hostname/manifest.mjs b/cubes/admin/hostname/manifest.mjs new file mode 100644 index 0000000..26c0e56 --- /dev/null +++ b/cubes/admin/hostname/manifest.mjs @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { cubes, uniqid } from '@bitstack/nopy'; + +/** + * Manifest for the admin:hostname cube. + * This cube allows for setting and persistently changing the system's hostname. + */ +export default cubes.Manifest({ + id: 'admin:hostname', + name: 'Permanently change the hostname', + dependencies: () => [], + schema: z.object({ + HOSTNAME: z.string().min(1).max(64).describe('The new hostname for the target host') + .default(`host-${uniqid()}`), + }) +}); diff --git a/cubes/admin/locale/README.md b/cubes/admin/locale/README.md new file mode 100644 index 0000000..f02ec7f --- /dev/null +++ b/cubes/admin/locale/README.md @@ -0,0 +1,23 @@ +# Cube: admin/locale + +Configures system keyboard layout permanently by updating `/etc/default/keyboard` and using `localectl`. + +## Configuration + +- `LAYOUT` (string): Keyboard layout (e.g. "ch", "us", "de"). Default: "ch". +- `MODEL` (string): Keyboard model. Default: "pc105". +- `VARIANT` (string): Keyboard variant. Default: "". +- `OPTIONS` (string): Keyboard options (comma separated). Default: "". + +## Usage + +```javascript +import { cubes } from '@bitstack/nopy'; + +export default cubes.Manifest({ + name: 'My Host Setup', + dependencies: () => [ + ['admin:locale', { LAYOUT: 'de' }] + ] +}); +``` diff --git a/cubes/admin/locale/deploy.py b/cubes/admin/locale/deploy.py new file mode 100644 index 0000000..0f2b241 --- /dev/null +++ b/cubes/admin/locale/deploy.py @@ -0,0 +1,55 @@ +from pyinfra import host +from pyinfra.operations import server, files + +LAYOUT = host.data.LAYOUT +MODEL = host.data.MODEL +VARIANT = host.data.VARIANT +OPTIONS = host.data.OPTIONS + +# Update /etc/default/keyboard +files.line( + name="Update XKBMODEL in /etc/default/keyboard", + path="/etc/default/keyboard", + line=r'^XKBMODEL=.*', + replace=f'XKBMODEL="{MODEL}"', + _sudo=True, +) + +files.line( + name="Update XKBLAYOUT in /etc/default/keyboard", + path="/etc/default/keyboard", + line=r'^XKBLAYOUT=.*', + replace=f'XKBLAYOUT="{LAYOUT}"', + _sudo=True, +) + +files.line( + name="Update XKBVARIANT in /etc/default/keyboard", + path="/etc/default/keyboard", + line=r'^XKBVARIANT=.*', + replace=f'XKBVARIANT="{VARIANT}"', + _sudo=True, +) + +files.line( + name="Update XKBOPTIONS in /etc/default/keyboard", + path="/etc/default/keyboard", + line=r'^XKBOPTIONS=.*', + replace=f'XKBOPTIONS="{OPTIONS}"', + _sudo=True, +) + +# Apply keyboard configuration +server.shell( + name="Apply keyboard setup", + commands=["setupcon", "service keyboard-setup restart"], + _sudo=True, +) + +# Set X11 keyboard layout using localectl if available +server.shell( + name="Set X11 keyboard layout using localectl", + commands=[f"localectl set-x11-keymap {LAYOUT} {MODEL} '{VARIANT}' '{OPTIONS}'"], + _sudo=True, + _ignore_errors=True, +) diff --git a/cubes/admin/locale/manifest.mjs b/cubes/admin/locale/manifest.mjs new file mode 100644 index 0000000..6630b65 --- /dev/null +++ b/cubes/admin/locale/manifest.mjs @@ -0,0 +1,14 @@ +import { z } from 'zod'; +import { cubes } from '@bitstack/nopy'; + +export default cubes.Manifest({ + id: 'admin:locale', + name: 'Configure system locale and keyboard layout', + dependencies: () => [], + schema: z.object({ + LAYOUT: z.string().describe('Keyboard layout (e.g. "ch", "us", "de")').default('ch'), + MODEL: z.string().describe('Keyboard model').default('pc105'), + VARIANT: z.string().describe('Keyboard variant').default(''), + OPTIONS: z.string().describe('Keyboard options (comma separated)').default(''), + }), +}); diff --git a/cubes/apt/essentials/README.md b/cubes/apt/essentials/README.md new file mode 100644 index 0000000..2e6b455 --- /dev/null +++ b/cubes/apt/essentials/README.md @@ -0,0 +1,39 @@ +# apt:essentials + +**Install essential packages** + +## Purpose + +This cube installs a curated collection of essential development tools and utilities that are commonly needed for server environments and development workflows. + +## What This Cube Does + +Installs the following packages via apt: + +- **fish** - User-friendly command-line shell with autosuggestions and syntax highlighting +- **ranger** - Terminal-based file manager with vi key bindings +- **golang-go** - Go programming language compiler and tools +- **build-essential** - Essential compilation tools (gcc, g++, make, etc.) +- **python3** - Python 3 interpreter and standard library +- **pkg-config** - Helper tool for compiling applications and libraries +- **age** - Modern file encryption tool with small explicit keys + +## Configuration + +### Parameters + +- **UPDATE** (boolean, default: `true`) + - If already installed, should packages be updated? + - Set to `false` to skip package cache updates and only install missing packages + +## Dependencies + +None - this cube can run standalone. + +## Use Cases + +This cube is ideal as a base dependency for other cubes that require: +- Basic development tools +- Modern shell environments +- File encryption capabilities +- Python or Go runtimes diff --git a/cubes/apt/essentials/deploy.py b/cubes/apt/essentials/deploy.py new file mode 100644 index 0000000..536d75c --- /dev/null +++ b/cubes/apt/essentials/deploy.py @@ -0,0 +1,18 @@ +from pyinfra.operations import apt +from pyinfra import host + +UPDATE = host.data.UPDATE + +apt.packages( + name='Install essentials', + packages=[ + 'fish', + 'build-essential', + 'python3', + 'pkg-config', + 'age' + ], + update=UPDATE, + _sudo=True +) + diff --git a/cubes/apt/essentials/manifest.mjs b/cubes/apt/essentials/manifest.mjs new file mode 100644 index 0000000..2df003b --- /dev/null +++ b/cubes/apt/essentials/manifest.mjs @@ -0,0 +1,11 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'apt:essentials', + name: 'Install essential packages', + dependencies: () => [], + schema: z.object({ + UPDATE: z.boolean().describe('If already installed should packages be updated').default(true), + }), +}); diff --git a/cubes/apt/install/README.md b/cubes/apt/install/README.md new file mode 100644 index 0000000..88b9c2d --- /dev/null +++ b/cubes/apt/install/README.md @@ -0,0 +1,62 @@ +# apt + +**Install packages with apt** + +## Purpose + +This is a generic cube for installing custom packages via the apt package manager. It's useful when you need to install specific packages that aren't covered by other specialized cubes. + +## What This Cube Does + +1. Optionally updates the apt package cache +2. Installs the specified space-separated list of packages + +## Configuration + +### Parameters + +- **UPDATE** (boolean, default: `true`) + - Update package cache before installing + - Set to `false` to skip updating and only install packages + +- **PACKAGES** (string, default: `''`) + - Space-separated list of packages to install + - Example: `"vim git htop curl wget"` + +## Dependencies + +None - this cube can run standalone. + +## Use Cases + +Install development tools: +``` +PACKAGES="vim git htop tmux" +``` + +Install database clients: +``` +PACKAGES="postgresql-client mysql-client redis-tools" +``` + +Install system utilities: +``` +PACKAGES="curl wget jq unzip zip" +``` + +## Example + +When deploying this cube, you would typically configure it like: +```javascript +exec('apt', { + PACKAGES: 'nginx certbot python3-certbot-nginx', + UPDATE: true +}) +``` + +## Notes + +- Package names must match exact apt package names +- Invalid package names will cause the installation to fail +- Use `apt search ` to find package names +- Some packages may require additional configuration after installation diff --git a/cubes/apt/install/deploy.py b/cubes/apt/install/deploy.py new file mode 100644 index 0000000..7cdf82d --- /dev/null +++ b/cubes/apt/install/deploy.py @@ -0,0 +1,31 @@ +from pyinfra.operations import apt +from pyinfra import host + +UPDATE = host.data.UPDATE +PACKAGES = str(host.data.PACKAGES).split(' ') + +apt.packages( + name='Install essential packages', + packages=[ + 'htop', + 'age', + 'git', + 'curl', + 'nano', + 'wget', + 'ca-certificates', + 'ufw', + "gnupg", + "lsb-release" + ], + update=UPDATE, + _sudo=True +) + +apt.packages( + name='Install custom packages', + packages=[p.strip() for p in PACKAGES if p], + update=UPDATE, + _sudo=True +) + diff --git a/cubes/apt/install/manifest.mjs b/cubes/apt/install/manifest.mjs new file mode 100644 index 0000000..ad74328 --- /dev/null +++ b/cubes/apt/install/manifest.mjs @@ -0,0 +1,12 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'apt:install', + name: 'Install packages with apt', + dependencies: () => [], + schema: z.object({ + UPDATE: z.boolean().describe('Update package cache before installing').default(false), + PACKAGES: z.string().describe('Space-separated list of packages to install').default('vim htop curl'), + }), +}); diff --git a/cubes/armor/fail2ban/README.md b/cubes/armor/fail2ban/README.md new file mode 100644 index 0000000..ef226e8 --- /dev/null +++ b/cubes/armor/fail2ban/README.md @@ -0,0 +1,41 @@ +# armor-fail2ban + +**Install and enable fail2ban** + +## Purpose + +This cube installs and configures Fail2ban, an intrusion prevention software that protects your server from brute-force attacks and unauthorized access attempts. + +## What is Fail2ban? + +Fail2ban monitors log files (e.g., `/var/log/auth.log`) for suspicious activity, such as repeated failed login attempts. When it detects malicious behavior patterns, it automatically: + +- Bans the offending IP address by updating firewall rules +- Prevents the attacker from making further connection attempts +- Can send email notifications about bans (if configured) + +Common use cases include: +- Protecting SSH from brute-force password attacks +- Blocking repeated failed login attempts on web applications +- Preventing DoS attacks from specific IP addresses + +## What This Cube Does + +1. Installs the `fail2ban` package via apt +2. Deploys a custom configuration file (`jail.local`) to `/etc/fail2ban/jail.local` +3. Configures fail2ban with sensible defaults for common services + +## Configuration + +This cube currently has no configurable parameters. The default configuration is applied from the included `jail.local` file. + +## Dependencies + +None - this cube can run standalone. + +## Notes + +After deployment, you can: +- Check fail2ban status: `sudo fail2ban-client status` +- View banned IPs: `sudo fail2ban-client status sshd` +- Unban an IP: `sudo fail2ban-client set sshd unbanip ` diff --git a/cubes/armor/fail2ban/deploy.py b/cubes/armor/fail2ban/deploy.py new file mode 100644 index 0000000..65b1569 --- /dev/null +++ b/cubes/armor/fail2ban/deploy.py @@ -0,0 +1,18 @@ +from pyinfra.operations import apt, files, server + +# Install Fail2ban +apt.packages( + name='Install Fail2ban', + packages=['fail2ban'], + update=True, + _sudo=True +) + +# Configure Fail2ban +files.put( + name='Configure Fail2ban', + src='jail.local', + dest='/etc/fail2ban/jail.local', + mode='0644', + _sudo=True +) \ No newline at end of file diff --git a/cubes/armor/fail2ban/jail.local b/cubes/armor/fail2ban/jail.local new file mode 100644 index 0000000..a7a3ca3 --- /dev/null +++ b/cubes/armor/fail2ban/jail.local @@ -0,0 +1,8 @@ +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 3 +findtime = 600 +bantime = 3600 \ No newline at end of file diff --git a/cubes/armor/fail2ban/manifest.mjs b/cubes/armor/fail2ban/manifest.mjs new file mode 100644 index 0000000..2386a56 --- /dev/null +++ b/cubes/armor/fail2ban/manifest.mjs @@ -0,0 +1,7 @@ +import { cubes } from '@bitstack/nopy'; + +export default cubes.Manifest({ + id: 'armor:fail2ban', + name: 'Install and enable fail2ban', + dependencies: () => [], +}); diff --git a/cubes/armor/ssh/README.md b/cubes/armor/ssh/README.md new file mode 100644 index 0000000..813b56c --- /dev/null +++ b/cubes/armor/ssh/README.md @@ -0,0 +1,83 @@ +# armor-ssh + +**Secure SSH server by disabling password authentication** + +## Purpose + +This cube hardens your SSH server configuration by disabling less secure authentication methods, enforcing SSH key-based authentication only. + +## Why Disable Password Authentication? + +Password-based SSH authentication is vulnerable to: +- **Brute-force attacks**: Automated scripts trying millions of password combinations +- **Dictionary attacks**: Guessing common passwords +- **Credential stuffing**: Using leaked passwords from other breaches +- **Weak passwords**: Users choosing easily guessable passwords + +**SSH key authentication is more secure** because: +- Keys are cryptographically strong (2048+ bit keys vs 8-12 character passwords) +- Private keys never travel over the network +- Immune to brute-force attacks +- Can be protected with passphrases for additional security + +## What This Cube Does + +1. **Disables challenge-response authentication** + - Prevents keyboard-interactive authentication prompts + +2. **Optionally disables password authentication** (default: enabled) + - Forces users to authenticate with SSH keys only + - Prevents password-based login attempts + +3. **Optionally disables PAM** (Pluggable Authentication Modules) + - Disables PAM-based authentication methods + - Reduces attack surface + +4. **Restarts SSH service** + - Applies the new configuration immediately + +## Configuration + +### Parameters + +- **DISABLE_PASSWORD** (boolean, default: `true`) + - Disable password authentication for SSH connections + - ⚠️ **WARNING**: Ensure you have SSH key access configured before enabling this! + +- **DISABLE_PAM** (boolean, default: `true`) + - Disable PAM (Pluggable Authentication Modules) for SSH + - Recommended for key-only authentication setups + +## Dependencies + +None - this cube can run standalone. + +## Security Best Practices + +**Before deploying this cube**: +1. Ensure you have SSH key authentication set up and tested +2. Keep an alternative access method available (console access, VNC, etc.) +3. Test SSH key login before disabling passwords +4. Consider using the `user-add` or `ssh-keyman` cubes first + +**After deployment**: +- Only SSH key authentication will work +- Password login attempts will be rejected +- Make sure to back up your private SSH key securely + +## Post-Installation + +The SSH service will restart automatically. Your current SSH session will remain active, but new connections must use SSH keys. + +To verify the configuration: +```bash +sudo grep -E "PasswordAuthentication|ChallengeResponseAuthentication|UsePAM" /etc/ssh/sshd_config +``` + +## Recovery + +If you get locked out: +1. Access the server via console (physical or cloud provider's web console) +2. Edit `/etc/ssh/sshd_config` +3. Set `PasswordAuthentication yes` +4. Restart SSH: `sudo systemctl restart ssh` diff --git a/cubes/armor/ssh/deploy.py b/cubes/armor/ssh/deploy.py new file mode 100644 index 0000000..7be1487 --- /dev/null +++ b/cubes/armor/ssh/deploy.py @@ -0,0 +1,43 @@ +from pyinfra.operations import files, server +from pyinfra import host +from pyinfra import config +import logging + +DISABLE_PASSWORD=host.data.DISABLE_PASSWORD +DISABLE_PAM=host.data.DISABLE_PAM + +logger = logging.getLogger(__name__) +config.SUDO = True + +files.line( + name='Disable challenge-response authentication in SSH', + path='/etc/ssh/sshd_config', + line='ChallengeResponseAuthentication yes', + replace='ChallengeResponseAuthentication no', +) + +if DISABLE_PASSWORD: + files.line( + name='Disable password authentication in SSH', + path='/etc/ssh/sshd_config', + line='PasswordAuthentication yes', + replace='PasswordAuthentication no', +) +else: + logger.info('Password authentication allowed') + +if DISABLE_PAM: + files.line( + name='Disable PAM in SSH', + path='/etc/ssh/sshd_config', + line='UsePAM yes', + replace='UsePAM no', + ) + +# Restart SSH service +server.service( + 'ssh', + running=True, + restarted=True, + reloaded=True +) \ No newline at end of file diff --git a/cubes/armor/ssh/manifest.mjs b/cubes/armor/ssh/manifest.mjs new file mode 100644 index 0000000..092b884 --- /dev/null +++ b/cubes/armor/ssh/manifest.mjs @@ -0,0 +1,18 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'armor:ssh', + name: 'Secure SSH server by disabling password authentication', + dependencies: () => [], + schema: z.object({ + DISABLE_PASSWORD: z + .boolean() + .describe('Disable password authentication for SSH connections') + .default(true), + DISABLE_PAM: z + .boolean() + .describe('Disable PAM (Pluggable Authentication Modules) for SSH') + .default(true), + }), +}); diff --git a/cubes/armor/ufw/README.md b/cubes/armor/ufw/README.md new file mode 100644 index 0000000..8650062 --- /dev/null +++ b/cubes/armor/ufw/README.md @@ -0,0 +1,77 @@ +# armor-ufw + +**Activate UFW (Uncomplicated Firewall)** + +## Purpose + +This cube configures and enables UFW, a user-friendly firewall management tool for Linux systems, providing basic protection against unauthorized network access. + +## What is UFW? + +UFW (Uncomplicated Firewall) is a frontend for `iptables` designed to make firewall configuration simple and accessible. It provides: + +- **Easy-to-understand syntax**: Commands like `ufw allow ssh` instead of complex iptables rules +- **Default deny policy**: Blocks all incoming connections except those explicitly allowed +- **Connection tracking**: Automatically handles related and established connections +- **Application profiles**: Pre-configured rules for common services + +Think of UFW as a security gate for your server - it controls which network traffic is allowed in and out. + +## What This Cube Does + +1. Configures UFW to allow SSH connections (port 22) + - Ensures you don't lock yourself out when enabling the firewall +2. Optionally allows HTTP traffic (port 80) based on the `ALLOW_HTTP` parameter +3. Enables the firewall with the configured rules + +## Configuration + +### Parameters + +- **ALLOW_HTTP** (boolean, default: `true`) + - Allow incoming HTTP traffic on port 80 + - Set to `false` if you're only using HTTPS or don't need web traffic + +## Dependencies + +- **apt:essentials** - Required for basic system tools + +## Security Notes + +**Important**: This cube automatically allows SSH to prevent lockouts. If you need to allow additional services, you can run: + +```bash +sudo ufw allow [port number]/[protocol] +sudo ufw allow [service-name] +``` + +Examples: +- `sudo ufw allow 443/tcp` - Allow HTTPS +- `sudo ufw allow 3000/tcp` - Allow custom application port +- `sudo ufw allow https` - Allow HTTPS by service name + +## Post-Installation + +Check firewall status: +```bash +sudo ufw status verbose +sudo ufw status numbered +``` + +Common UFW commands: +- Delete rule: `sudo ufw delete [rule number]` +- Disable firewall: `sudo ufw disable` +- Reset to defaults: `sudo ufw reset` + +## UFW File Locations + +UFW rules are stored in the `/etc/ufw` directory: + +- `/etc/ufw/user.rules` - Custom rules added via the `ufw` command +- `/etc/ufw/before.rules` - Rules processed before user rules (high priority) +- `/etc/ufw/after.rules` - Rules processed after user rules (exceptions) +- `/etc/ufw/sysctl.conf` - Kernel network parameters (e.g., packet forwarding) +- `/etc/ufw/applications.d/` - Application profiles for common services +- `/etc/default/ufw` - Global UFW settings and default policies + +Understanding these locations is helpful for troubleshooting, manual edits, or backing up your firewall configuration. diff --git a/cubes/armor/ufw/deploy.py b/cubes/armor/ufw/deploy.py new file mode 100644 index 0000000..85496d0 --- /dev/null +++ b/cubes/armor/ufw/deploy.py @@ -0,0 +1,13 @@ +from pyinfra.operations import server +from pyinfra import host + +ALLOW_HTTP=host.data.ALLOW_HTTP + +server.shell( + commands=[ + f"ufw allow ssh", + f"ufw allow http" if ALLOW_HTTP else "", + f"ufw enable", + ], + _sudo=True +) \ No newline at end of file diff --git a/cubes/armor/ufw/manifest.mjs b/cubes/armor/ufw/manifest.mjs new file mode 100644 index 0000000..9d5cbb2 --- /dev/null +++ b/cubes/armor/ufw/manifest.mjs @@ -0,0 +1,11 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'armor:ufw', + name: 'Activate ufw (uncomplicated firewall)', + dependencies: () => ['apt:essentials'], + schema: z.object({ + ALLOW_HTTP: z.boolean().describe('Allow incoming HTTP traffic on port 80').default(true), + }), +}); diff --git a/cubes/caddy/base/README.md b/cubes/caddy/base/README.md new file mode 100644 index 0000000..f68d66b --- /dev/null +++ b/cubes/caddy/base/README.md @@ -0,0 +1,120 @@ +# caddy + +**Install Caddy webserver** + +## Purpose + +This cube installs Caddy, a modern, powerful web server with automatic HTTPS that's designed to be easy to use and configure. + +## What is Caddy? + +Caddy is a next-generation web server that stands out for its simplicity and built-in security: + +- **Automatic HTTPS**: Automatically obtains and renews SSL/TLS certificates from Let's Encrypt +- **Modern HTTP features**: HTTP/2, HTTP/3 (QUIC) support out of the box +- **Simple configuration**: Human-readable Caddyfile format +- **Reverse proxy**: Easy proxying to backend applications +- **Static file serving**: Fast and efficient static site hosting +- **Zero-downtime reloads**: Update config without dropping connections + +## What This Cube Does + +1. **Adds Caddy's official repository** + - Installs required dependencies (debian-keyring, apt-transport-https, curl) + - Downloads and installs Caddy's GPG signing key + - Configures APT to use Caddy's official stable repository + +2. **Installs Caddy** + - Installs the latest stable version of Caddy + - Sets up the Caddy service + +3. **Configures TLS** + - Creates a Caddyfile with a reusable TLS snippet + - Configures TLS based on the `TLS` parameter + +## Configuration + +### Parameters + +- **TLS** (string, default: `''`) + - TLS certificate configuration + - **Options**: + - `''` (empty string) - Automatic HTTPS with Let's Encrypt (recommended) + - `'internal'` - Use Caddy's internal CA for self-signed certs (testing only) + - `'/path/to/cert /path/to/key'` - Provide custom certificate paths + +## Dependencies + +None - this cube can run standalone. + +## TLS Configuration Examples + +**Automatic HTTPS (Production)**: +```javascript +exec('caddy', { TLS: '' }) +``` +Caddy will automatically obtain SSL certificates from Let's Encrypt for your domain. + +**Self-Signed for Testing**: +```javascript +exec('caddy', { TLS: 'internal' }) +``` +Uses Caddy's internal CA. Browsers will show security warnings. + +**Custom Certificates**: +```javascript +exec('caddy', { TLS: '/etc/ssl/certs/mycert.pem /etc/ssl/private/mykey.pem' }) +``` +Use your own certificate and private key files. + +## Post-Installation + +The Caddyfile is created at `/etc/caddy/Caddyfile` with a reusable TLS snippet: + +``` +(tls_cert) { + tls {TLS} +} +``` + +Other cubes (like `caddy-spa`) can import this snippet with `import tls_cert`. + +## Managing Caddy + +Start/stop/restart Caddy: +```bash +sudo systemctl start caddy +sudo systemctl stop caddy +sudo systemctl restart caddy +sudo systemctl status caddy +``` + +Reload configuration without downtime: +```bash +sudo systemctl reload caddy +``` + +Test configuration: +```bash +caddy validate --config /etc/caddy/Caddyfile +``` + +## Common Use Cases + +- Reverse proxy for Node.js/Python/Go apps +- Static website hosting +- API gateway +- Load balancer +- SSL/TLS termination + +## Notes + +- Caddy runs on ports 80 (HTTP) and 443 (HTTPS) by default +- Ensure these ports are open in your firewall (UFW) +- For automatic HTTPS, your domain must point to your server's IP +- Caddy automatically redirects HTTP to HTTPS when using automatic HTTPS + +## Additional Resources + +- [Caddy Documentation](https://caddyserver.com/docs/) +- [Caddyfile Tutorial](https://caddyserver.com/docs/caddyfile-tutorial) diff --git a/cubes/caddy/base/deploy.py b/cubes/caddy/base/deploy.py new file mode 100644 index 0000000..a11ee95 --- /dev/null +++ b/cubes/caddy/base/deploy.py @@ -0,0 +1,34 @@ +from pyinfra.operations import apt, server, files +from pyinfra import host +from io import StringIO + +# 🔹 Variables +TLS = host.data.TLS + +server.shell( + commands=[ + f"sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl", + f"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg", + f"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list" + ] +) + +apt.packages( + name='Install caddy', + packages=['caddy'], + update=True, + _sudo=True +) + + +TLS_BLOCK = f""" +(tls_cert) {{ + tls {TLS} +}} +""" + +files.put( + src = StringIO(TLS_BLOCK), # local filename to upload, + dest = '/etc/caddy/Caddyfile', # the remote filename to upload to + _sudo=True +) \ No newline at end of file diff --git a/cubes/caddy/base/manifest.mjs b/cubes/caddy/base/manifest.mjs new file mode 100644 index 0000000..67279ee --- /dev/null +++ b/cubes/caddy/base/manifest.mjs @@ -0,0 +1,19 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'caddy', + name: 'Install Caddy webserver', + dependencies: () => [], + schema: z.object({ + // tls /path/to/cert.pem /path/to/key.pem + TLS: z + .string() + .default('') + .describe(` + --> enabled and automatically managed + internal --> use Caddy custom root CA for self-signed certs (for testing purpose) + /path/to/cert /path/to/key --> Provide custom certificates + `), + }), +}); diff --git a/cubes/caddy/spa/README.md b/cubes/caddy/spa/README.md new file mode 100644 index 0000000..5c5de1b --- /dev/null +++ b/cubes/caddy/spa/README.md @@ -0,0 +1,137 @@ +# caddy-spa + +**Configure Caddy to serve a Single Page Application** + +## Purpose + +This cube adds a reverse proxy configuration to Caddy for serving a Single Page Application (SPA) from a local backend server, with automatic HTTPS support. + +## What This Cube Does + +1. **Adds domain configuration to Caddyfile** + - Creates a site block for the specified domain + - Configures reverse proxy to forward traffic to local application + - Imports TLS configuration from the `caddy` cube + +2. **Configures reverse proxy** + - Proxies all requests to `localhost:{PORT}` + - Preserves headers and client information + - Handles WebSocket connections + +3. **Restarts Caddy service** + - Applies the new configuration immediately + +## Configuration + +### Parameters + +- **DOMAIN** (string, default: `''`) + - Domain name for the SPA application + - Example: `'myapp.example.com'` + - Must have DNS pointing to your server's IP + +- **PORT** (number, default: `5432`) + - Port number where the SPA will be served + - Your application should be listening on this port locally + +## Dependencies + +- **caddy** cube (implicitly required) - Must be installed first to provide the `tls_cert` snippet + +## What Gets Configured + +This cube adds the following to `/etc/caddy/Caddyfile`: + +``` +# BEGIN DOMAIN myapp.example.com +myapp.example.com { + import tls_cert + reverse_proxy localhost:5432 +} +# END myapp.example.com +``` + +## Use Cases + +**Deploy a React/Vue/Angular app**: +```javascript +exec('caddy-spa', { + DOMAIN: 'app.example.com', + PORT: 3000 +}) +``` + +**Deploy multiple SPAs**: +```javascript +exec('caddy-spa', { DOMAIN: 'app1.example.com', PORT: 3000 }) +exec('caddy-spa', { DOMAIN: 'app2.example.com', PORT: 3001 }) +exec('caddy-spa', { DOMAIN: 'app3.example.com', PORT: 3002 }) +``` + +## How It Works + +1. User visits `https://myapp.example.com` +2. Caddy receives the request on port 443 (HTTPS) +3. Caddy automatically handles SSL/TLS encryption +4. Request is forwarded to `localhost:5432` +5. Your application receives the request and returns a response +6. Caddy sends the encrypted response back to the user + +## Prerequisites + +Before deploying this cube: + +1. **Install the caddy cube first** + - Provides the base Caddy installation and TLS configuration + +2. **Ensure your application is running** + - Your SPA backend should be listening on the specified PORT + - Example: `npm start` or `pm2 start app.js` + +3. **Configure DNS** + - Point your domain's A record to your server's IP address + - Wait for DNS propagation (can take a few minutes to hours) + +4. **Open firewall ports** + - Ensure ports 80 and 443 are open (for automatic HTTPS) + - `sudo ufw allow 80/tcp` + - `sudo ufw allow 443/tcp` + +## Post-Installation + +Verify the configuration: +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +``` + +Check Caddy status: +```bash +sudo systemctl status caddy +``` + +View Caddy logs: +```bash +sudo journalctl -u caddy -f +``` + +## Common Issues + +**502 Bad Gateway**: +- Your application isn't running on the specified PORT +- Check: `netstat -tlnp | grep {PORT}` + +**Certificate errors**: +- DNS not pointing to your server +- Ports 80/443 blocked by firewall +- Check Caddy logs: `sudo journalctl -u caddy -f` + +**Domain not resolving**: +- DNS propagation not complete yet +- Verify with: `dig +short myapp.example.com` + +## Notes + +- Caddy automatically obtains and renews Let's Encrypt certificates +- The reverse proxy preserves the original client IP and headers +- WebSocket connections are automatically supported +- You can add multiple domains by running this cube multiple times with different parameters diff --git a/cubes/caddy/spa/deploy.py b/cubes/caddy/spa/deploy.py new file mode 100644 index 0000000..bd49a1f --- /dev/null +++ b/cubes/caddy/spa/deploy.py @@ -0,0 +1,32 @@ +from pyinfra.operations import apt, server, files +from pyinfra import host +from io import StringIO + +# 🔹 Variables (Modify as Needed) +DOMAIN = host.data.DOMAIN +PORT = host.data.PORT + + +site_block = f"""# BEGIN DOMAIN {DOMAIN} +{DOMAIN} {{ + import tls_cert + reverse_proxy localhost:{PORT} +}} +# END {DOMAIN} +""" + +files.block( + path = '/etc/caddy/Caddyfile', + content = site_block, + present = True, + before = False, + after = False, + _sudo = True +) + +server.service( + service='caddy', + running=True, + restarted=True, + _sudo=True +) \ No newline at end of file diff --git a/cubes/caddy/spa/manifest.mjs b/cubes/caddy/spa/manifest.mjs new file mode 100644 index 0000000..e5ee17f --- /dev/null +++ b/cubes/caddy/spa/manifest.mjs @@ -0,0 +1,12 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'caddy:spa', + name: 'Install single page application', + dependencies: () => [], + schema: z.object({ + DOMAIN: z.string().describe('Domain name for the SPA application').default(''), + PORT: z.number().describe('Port number where the SPA will be served').default(5432), + }), +}); diff --git a/cubes/git/clone/deploy.py b/cubes/git/clone/deploy.py new file mode 100644 index 0000000..bb59d8b --- /dev/null +++ b/cubes/git/clone/deploy.py @@ -0,0 +1,18 @@ +from pyinfra.operations import server +from pyinfra import host + +USER = host.data.USER +REPO = host.data.REPO +APP = host.data.APP + +server.shell( + name="Clone application", + commands=[ + f""" + cd $HOME && + git clone --recurse-submodules {REPO} {APP} + """], + _sudo=True, + _sudo_user=USER, + _use_sudo_login=True +) \ No newline at end of file diff --git a/cubes/git/clone/manifest.mjs b/cubes/git/clone/manifest.mjs new file mode 100644 index 0000000..3f73d9d --- /dev/null +++ b/cubes/git/clone/manifest.mjs @@ -0,0 +1,13 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'git:clone', + name: 'Clone a repository', + dependencies: () => [], + schema: z.object({ + USER: z.string().describe('Username for which to clone the repository').default('vagrant'), + REPO: z.string().default(''), + APP: z.string().describe('The internal name used for this application').default(''), + }), +}); diff --git a/cubes/network/tailscale/README.md b/cubes/network/tailscale/README.md new file mode 100644 index 0000000..d20e047 --- /dev/null +++ b/cubes/network/tailscale/README.md @@ -0,0 +1,27 @@ +# Tailscale Cube + +Installs and authenticates the Tailscale client on a Linux host. + +## Features + +- **Automated Installation**: Adds the official Tailscale repository and installs the package. +- **Headless Authentication**: Uses a Tailscale Auth Key for zero-interaction setup. +- **Headscale Support**: Can be configured to connect to a custom login server. +- **Startup persistence**: Ensures the `tailscaled` daemon is enabled and running. + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `AUTH_KEY` | `""` | Tailscale Auth Key (recommended to use a 'reusable' or 'ephemeral' key). | +| `LOGIN_SERVER` | `https://controlplane.tailscale.com` | The coordination server URL. Set this to your Headscale instance URL if applicable. | +| `EXTRA_ARGS` | `""` | Additional flags to pass to `tailscale up` (e.g., `--advertise-exit-node`). | +| `FORCE_REAUTH` | `false` | If true, forces the client to re-authenticate. | + +## Usage + +```bash +nopy install tailscale +``` + +When prompted, provide your `AUTH_KEY`. If you are using Headscale, also provide the `LOGIN_SERVER` URL. diff --git a/cubes/network/tailscale/deploy.py b/cubes/network/tailscale/deploy.py new file mode 100644 index 0000000..9cc84ba --- /dev/null +++ b/cubes/network/tailscale/deploy.py @@ -0,0 +1,45 @@ +from pyinfra import host +from pyinfra.operations import server, apt + +# Variables from manifest +AUTH_KEY = host.data.AUTH_KEY +LOGIN_SERVER = host.data.LOGIN_SERVER +EXTRA_ARGS = host.data.EXTRA_ARGS +FORCE_REAUTH = host.data.FORCE_REAUTH + +# 1. Install Tailscale using the official one-liner script +server.shell( + name="Install Tailscale", + commands=["curl -fsSL https://tailscale.com/install.sh | sh"], + _sudo=True +) + +# 2. Ensure Tailscale is enabled and running +# Manually (Linux): sudo systemctl enable --now tailscaled +server.service( + name="Ensure tailscaled is running and enabled on boot", + service="tailscaled", + running=True, + enabled=True, + _sudo=True +) + +# 3. Authenticate and bring Tailscale up +# We use --authkey for headless mode +# We use --login-server if it's different from the default +up_command = f"tailscale up --authkey {AUTH_KEY}" + +if LOGIN_SERVER and LOGIN_SERVER != "https://controlplane.tailscale.com": + up_command += f" --login-server {LOGIN_SERVER}" + +if FORCE_REAUTH: + up_command += " --force-reauth" + +if EXTRA_ARGS: + up_command += f" {EXTRA_ARGS}" + +server.shell( + name="Authenticate Tailscale (Headless)", + commands=[up_command], + _sudo=True +) diff --git a/cubes/network/tailscale/manifest.mjs b/cubes/network/tailscale/manifest.mjs new file mode 100644 index 0000000..242a150 --- /dev/null +++ b/cubes/network/tailscale/manifest.mjs @@ -0,0 +1,17 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'net:tailscale', + name: 'Install and authenticate Tailscale', + dependencies: () => ['apt:essentials'], + schema: z.object({ + AUTH_KEY: z.string().describe('Tailscale Auth Key for headless authentication').default(''), + LOGIN_SERVER: z + .string() + .describe('Custom login server (e.g., for Headscale)') + .default('https://controlplane.tailscale.com'), + EXTRA_ARGS: z.string().describe('Additional arguments for tailscale up').default(''), + FORCE_REAUTH: z.boolean().describe('Force re-authentication').default(false), + }), +}); diff --git a/cubes/network/wifi/access-point/README.md b/cubes/network/wifi/access-point/README.md new file mode 100644 index 0000000..058d00c --- /dev/null +++ b/cubes/network/wifi/access-point/README.md @@ -0,0 +1,234 @@ +# WiFi Access Point Cube (NetworkManager) + +Configures a Linux device as a WiFi Access Point using NetworkManager's `nmcli` command. **This approach allows the device to simultaneously act as an AP AND remain connected to another WiFi network as a client.** + +## Key Features + +- ✅ **Dual WiFi Mode**: Acts as AP while staying connected to another WiFi +- ✅ **Modern Approach**: Uses NetworkManager (nmcli) instead of hostapd +- ✅ **Auto-start**: Configured to start on boot +- ✅ **IP Sharing**: Built-in internet sharing with `ipv4.method shared` +- ✅ **Simple Configuration**: Minimal parameters, maximum functionality +- ✅ **Supports 2.4GHz and 5GHz bands** + +## Requirements + +- Device with WiFi capability (e.g., Raspberry Pi 4/5) +- NetworkManager installed and running +- WiFi hardware that supports AP mode + +## Configuration Parameters + +### Required + +- **SSID**: WiFi network name (1-32 characters) +- **PASSWORD**: WPA2 password (8-63 characters) + +### Optional (with defaults) + +- **NETWORK_DEVICE**: WiFi interface to use (`wlan0` default) +- **BAND**: Frequency band - `2.4GHz` or `5GHz` (auto-detected from current connection if not specified) +- **CHANNEL**: WiFi channel (auto-detected from current connection if not specified) + - 2.4GHz: 1-14 + - 5GHz: 36-165 +- **IP_ADDRESS**: AP gateway address (`192.168.50.1` default) +- **CONNECTION_NAME**: NetworkManager connection name (`net:wifi:ap` default) + +## Example Usage + +### Basic Configuration + +```json +{ + "SSID": "MyHomeWiFi", + "PASSWORD": "SecurePass123" +} +``` + +### Advanced Configuration (5GHz with specific channel) + +```json +{ + "SSID": "FastWiFi5G", + "PASSWORD": "SuperSecure456", + "NETWORK_DEVICE": "wlan1", + "BAND": "5GHz", + "CHANNEL": 36, + "IP_ADDRESS": "10.0.0.1", + "CONNECTION_NAME": "my-hotspot" +} +``` + +### Auto-Detection Configuration + +When `BAND` and `CHANNEL` are not specified, the script will automatically detect the current AP's band and channel using `iw dev link` and use those values for the new hotspot. This is useful when you want the hotspot to operate on the same band/channel as your existing connection to avoid interference. + +```json +{ + "SSID": "MyAutoAP", + "PASSWORD": "SecurePass123" +} +``` + +## What This Cube Does + +1. **Installs NetworkManager** (if not present) +2. **Enables NetworkManager service** and ensures it's running +3. **Auto-detects current AP settings** using `iw dev link` to find the band and channel +4. **Creates WiFi hotspot** using `nmcli` with auto-detected or specified settings +5. **Configures IP sharing** with `ipv4.method shared` (automatic DHCP + NAT) +6. **Enables IP forwarding** for internet routing +7. **Sets autoconnect** so the AP starts on boot + +## How It Works (Dual WiFi Mode) + +### Simultaneous AP + Client Mode + +**Hardware Support Required:** + +- Your WiFi hardware must support **simultaneous AP+STA (Station) mode** +- Most modern WiFi chips support this (e.g., Raspberry Pi 4/5, Intel WiFi cards) +- Older hardware may not support it (e.g., Raspberry Pi 3B and earlier have limitations) + +**How NetworkManager Handles It:** + +1. **Single Interface (e.g., wlan0):** + - If hardware supports AP+STA: ✅ **Both client and AP run on same interface** + - If hardware doesn't support it: ⚠️ **Client connection may be dropped** + +2. **Multiple Interfaces (e.g., wlan0 + wlan1):** + - NetworkManager will use one for client, another for AP + - Always works regardless of hardware capabilities + +**Example Scenario (Raspberry Pi 4):** + +``` +[Internet] <--WiFi--> [RPi4 wlan0 (Client+AP)] <--WiFi--> [Devices connect to AP] +``` + +**How to Check Hardware Support:** + +```bash +iw list | grep -A 10 "valid interface combinations" +``` + +Look for: `* #{ managed } <= 1, #{ AP } <= 1` or `* #{ managed, AP } <= 2` + +NetworkManager will: + +- Share internet from **any** available source (WiFi client, Ethernet, cellular, etc.) +- Automatically handle routing and NAT +- Try to maintain client connection if hardware supports it + +## Post-Installation + +After deployment, the device will: + +- ✅ Broadcast the WiFi network with your SSID +- ✅ Accept connections with your password +- ✅ Assign IP addresses to connected clients (via built-in DHCP) +- ✅ Share internet connection from any available interface +- ✅ Auto-start the AP on every boot +- ✅ Maintain client WiFi connection (if connected to another network) + +## Managing the Hotspot + +### View connection status + +```bash +nmcli connection show +``` + +### Stop the hotspot + +```bash +sudo nmcli connection down net:wifi:ap +``` + +### Start the hotspot + +```bash +sudo nmcli connection up net:wifi:ap +``` + +### Disable autostart + +```bash +sudo nmcli connection modify net:wifi:ap connection.autoconnect no +``` + +### Delete the hotspot + +```bash +sudo nmcli connection delete net:wifi:ap +``` + +## Troubleshooting + +### AP doesn't start + +- Check NetworkManager status: `sudo systemctl status NetworkManager` +- Verify WiFi interface exists: `nmcli device status` +- Check if AP mode is supported: `iw list | grep -A 10 "Supported interface modes"` + +### Can't connect to AP + +- Verify password is correct (8+ characters) +- Check channel compatibility with your devices +- Try switching between 2.4GHz and 5GHz bands + +### No internet on clients + +- Verify host device has internet: `ping 8.8.8.8` +- Check IP forwarding: `sysctl net.ipv4.ip_forward` +- NetworkManager should handle NAT automatically with `ipv4.method shared` + +### Hotspot conflicts with client WiFi + +- This shouldn't happen with NetworkManager +- If it does, check if hardware supports simultaneous AP+STA mode: + + ```bash + iw list | grep "valid interface combinations" + ``` + +## Advantages Over Traditional Approach + +| Feature | Traditional (hostapd) | NetworkManager (nmcli) | +|---------|----------------------|------------------------| +| **Dual WiFi** | ❌ No (conflicts with wpa_supplicant) | ✅ Yes (AP + client simultaneously) | +| **Configuration** | Complex (multiple files) | Simple (one command) | +| **DHCP** | Manual (dnsmasq) | Automatic | +| **NAT** | Manual (iptables) | Automatic | +| **Management** | Multiple services | Single service | +| **Dependencies** | hostapd, dnsmasq, iptables | NetworkManager only | + +## Security Notes + +- Always use a strong password (minimum 8 characters) +- WPA2 encryption is automatically enabled +- NetworkManager handles firewall rules automatically + +## Compatibility + +Tested on: + +- Raspberry Pi 4/5 with built-in WiFi +- Ubuntu/Debian-based systems with NetworkManager +- Devices with WiFi hardware supporting AP mode + +**Note**: Older Raspberry Pi models (3B and earlier) may have limitations with simultaneous AP + client mode due to hardware constraints. + +## Troubleshooting + +```bash + + +sudo nmcli con add type wifi con-name MyHotspot ifname wlan0 mode ap ssid YourNewAP ipv4.method shared wifi-sec.key-mgmt wpa-psk wifi-sec.psk "YourNewPassword" +sudo nmcli con up MyHotspot +sudo nmcli con down MyHotspot +sudo nmcli con mod MyHotspot wifi.band wifi.channel +# Find the currently used band and channel +iw dev wlan0 link + +``` diff --git a/cubes/network/wifi/access-point/deploy.py b/cubes/network/wifi/access-point/deploy.py new file mode 100644 index 0000000..ad003e5 --- /dev/null +++ b/cubes/network/wifi/access-point/deploy.py @@ -0,0 +1,70 @@ +from pyinfra.operations import apt, server, systemd +from pyinfra import host + +# Extract variables with proper defaults +SSID = host.data.get('SSID') +PASSWORD = host.data.get('PASSWORD') +BAND = host.data.get('BAND', '2.4GHz') +CHANNEL = host.data.get('CHANNEL') # Optional +AP_IP = host.data.get('AP_IP') +CONNECTION_NAME = host.data.get('CONNECTION_NAME') + +# Determine band configuration +if BAND == '5GHz': + BAND = 'a' + default_channel = 36 if not CHANNEL else CHANNEL +else: # 2.4GHz + BAND = 'bg' + default_channel = 6 if not CHANNEL else CHANNEL + +# Install NetworkManager if not present +apt.packages( + name='Install NetworkManager', + packages=['network-manager'], + update=True, + _sudo=True +) + +# Ensure NetworkManager is running +systemd.service( + name='Ensure NetworkManager is running', + service='NetworkManager', + running=True, + enabled=True, + _sudo=True +) + +# Check if connection already exists and delete it +server.shell( + name='Delete existing AP connection if present', + commands=[f'nmcli connection delete {CONNECTION_NAME} || true'], + _sudo=True +) + +# Add Wifi AP connection +server.shell( + name='Detect WiFi interface', + commands=[ + f'sudo nmcli con add type wifi con-name {CONNECTION_NAME} ifname wlan0 mode ap ssid {SSID} ipv4.method shared wifi-sec.key-mgmt wpa-psk wifi-sec.psk "{PASSWORD}"', + # CRITICAL: Force WPA2 (rsn) instead of WPA (wpa) + f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.proto rsn', + + f'sudo nmcli connection modify {CONNECTION_NAME} ipv4.addresses {AP_IP}/24', + + # Use AES encryption (CCMP) instead of TKIP + f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.pairwise ccmp', + f'sudo nmcli connection modify {CONNECTION_NAME} wifi-sec.group ccmp', + + # Set band + f'sudo nmcli connection modify {CONNECTION_NAME} 802-11-wireless.band {BAND}', + + # Enable autostart + f'sudo nmcli connection modify {CONNECTION_NAME} connection.autoconnect yes', + f'sudo nmcli connection modify {CONNECTION_NAME} connection.autoconnect-priority 10', + + # Start the AP + f'sudo nmcli connection up {CONNECTION_NAME}' + ], + _sudo=True +) + diff --git a/cubes/network/wifi/access-point/manifest.mjs b/cubes/network/wifi/access-point/manifest.mjs new file mode 100644 index 0000000..b0fc875 --- /dev/null +++ b/cubes/network/wifi/access-point/manifest.mjs @@ -0,0 +1,20 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'net:wifi:access-point', + name: 'Configure WiFi Access Point (NetworkManager)', + dependencies: () => [], + schema: z.object({ + SSID: z.string().min(1).max(32).default('PiPoint').describe('WiFi network name (SSID)'), + PASSWORD: z + .string() + .min(8) + .max(63) + .default('1223334444') + .describe('WPA2 password (8-63 characters)'), + BAND: z.enum(['2.4GHz', '5GHz']).default('2.4GHz').describe('Frequency band'), + AP_IP: z.string().default('192.168.4.1').describe('AP IP address'), + CONNECTION_NAME: z.string().default('pi-point').describe('NetworkManager connection name'), + }), +}); diff --git a/cubes/network/wifi/connection/README.md b/cubes/network/wifi/connection/README.md new file mode 100644 index 0000000..1762bc4 --- /dev/null +++ b/cubes/network/wifi/connection/README.md @@ -0,0 +1,54 @@ +# network:wifi:connection + +**Configure a WiFi client connection using NetworkManager** + +## Purpose + +This cube allows you to connect your target host (e.g., a Raspberry Pi or a laptop) to an existing WiFi network using `nmcli`. + +## What This Cube Does + +1. Ensures `network-manager` is installed and running +2. Removes any existing connection with the same name to avoid conflicts +3. Connects to the specified `SSID` using the provided `PASSWORD` +4. Configures the connection to automatically connect on boot (optional) + +## Configuration + +| Variable | Type | Description | Required | Default | +| :--- | :--- | :--- | :--- | :--- | +| `SSID` | `string` | The WiFi network name | Yes | - | +| `PASSWORD` | `string` | The WiFi password | Yes | - | +| `AUTOCONNECT` | `boolean` | Automatically connect to this network | No | `true` | +| `CONNECTION_NAME` | `string` | Name for the connection in NetworkManager | No | `SSID` | + +## Dependencies + +- `apt/essentials`: Basic system utilities. + +## Usage + +### Simple Connection + +```bash +nopy install network:wifi:connection --env SSID="MyHomeWiFi" --env PASSWORD="mysecurepassword" +``` + +### Connection with Custom Name and No Autoconnect + +```bash +nopy install network:wifi:connection --env SSID="OfficeWiFi" --env PASSWORD="password123" --env CONNECTION_NAME="Work" --env AUTOCONNECT=false +``` + +## Security Notes + +- WiFi passwords will be stored in `/etc/NetworkManager/system-connections/` on the target host. +- Passing passwords via `--env` may leave them in your local shell history. + +## Troubleshooting + +You can check the status of your WiFi connections on the target host with: +```bash +nmcli connection show +nmcli device status +``` diff --git a/cubes/network/wifi/connection/deploy.py b/cubes/network/wifi/connection/deploy.py new file mode 100644 index 0000000..28c1682 --- /dev/null +++ b/cubes/network/wifi/connection/deploy.py @@ -0,0 +1,43 @@ +from pyinfra import host +from pyinfra.operations import apt, server, systemd + +# [agnt://cogen/cogen/network-wifi-connection-2]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} + +""" +Deployment script for network:wifi:connection. +Uses nmcli to configure a WiFi client connection. +""" + +SSID = host.data.SSID +PASSWORD = host.data.PASSWORD +AUTOCONNECT = "yes" if host.data.get('AUTOCONNECT', True) else "no" +CONNECTION_NAME = host.data.get('CONNECTION_NAME', SSID) + +# Install NetworkManager if not present +apt.packages( + name='Install NetworkManager', + packages=['network-manager'], + update=True, + _sudo=True +) + +# Ensure NetworkManager is running +systemd.service( + name='Ensure NetworkManager is running', + service='NetworkManager', + running=True, + enabled=True, + _sudo=True +) + +# Add/Update the WiFi connection +# We delete first to ensure a clean state with the new password/settings +server.shell( + name=f"Configure WiFi connection for {SSID}", + commands=[ + f'nmcli connection delete "{CONNECTION_NAME}" || true', + f'nmcli device wifi connect "{SSID}" password "{PASSWORD}" name "{CONNECTION_NAME}"', + f'nmcli connection modify "{CONNECTION_NAME}" connection.autoconnect {AUTOCONNECT}', + ], + _sudo=True +) diff --git a/cubes/network/wifi/connection/manifest.mjs b/cubes/network/wifi/connection/manifest.mjs new file mode 100644 index 0000000..e7c9b8c --- /dev/null +++ b/cubes/network/wifi/connection/manifest.mjs @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { cubes } from '@bitstack/nopy'; + +// [agnt://cogen/cogen/network-wifi-connection-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} + +/** + * Manifest for the network:wifi:connection cube. + * Configures a WiFi client connection using NetworkManager (nmcli). + */ +export default cubes.Manifest({ + id: 'net:wifi:connection', + name: 'network:wifi:connection - Connect to a WiFi network', + schema: z.object({ + SSID: z.string().min(1).describe('The SSID of the WiFi network to connect to'), + PASSWORD: z.string().min(8).describe('The password for the WiFi network'), + AUTOCONNECT: z.boolean().default(true).describe('Whether to automatically connect to this network'), + CONNECTION_NAME: z.string().optional().describe('Optional name for the connection (defaults to SSID)'), + }) +}); diff --git a/cubes/runtime/docker/README.md b/cubes/runtime/docker/README.md new file mode 100644 index 0000000..a9fd3f5 --- /dev/null +++ b/cubes/runtime/docker/README.md @@ -0,0 +1,60 @@ +# docker + +**Install Docker and tools** + +## Purpose + +This cube installs Docker Engine, Docker Compose, and popular Docker management tools to enable containerized application deployment and management. + +## What is Docker? + +Docker is a platform for developing, shipping, and running applications in containers. Containers package an application with all its dependencies, ensuring it runs consistently across different environments. + +Key benefits: +- **Isolation**: Each container runs independently with its own filesystem, network, and processes +- **Portability**: Containers run the same way on any system that supports Docker +- **Efficiency**: Containers share the host OS kernel, making them lighter than virtual machines +- **Scalability**: Easily deploy and scale containerized applications + +## What This Cube Does + +1. **Adds Docker's official repository** + - Downloads and installs Docker's GPG key + - Configures APT to use Docker's official package repository + +2. **Installs Docker components** + - `docker-ce` - Docker Community Edition engine + - `docker-ce-cli` - Command-line interface for Docker + - `containerd.io` - Container runtime + - `docker-buildx-plugin` - Extended build capabilities with BuildKit + - `docker-compose-plugin` - Tool for defining multi-container applications + +3. **Installs management tools** + - **lazydocker** - Terminal UI for Docker and Docker Compose management + - **docker-ctop** - Container metrics and monitoring (top-like interface for containers) + +## Configuration + +This cube currently has no configurable parameters. + +## Dependencies + +None - this cube can run standalone. + +## Post-Installation + +After deployment: +- Add users to the `docker` group to run Docker without sudo: `sudo usermod -aG docker username` +- Start using Docker: `docker run hello-world` +- Use lazydocker for easy management: `lazydocker` +- Monitor containers: `ctop` + +## Notes + +The Docker daemon starts automatically on boot. You can manage it with systemd: +- Check status: `sudo systemctl status docker` +- Restart: `sudo systemctl restart docker` + +## Additional Resources + +- [Installation tutorial Ubuntu 24.04](https://www.cherryservers.com/blog/install-docker-ubuntu) \ No newline at end of file diff --git a/cubes/runtime/docker/deploy.py b/cubes/runtime/docker/deploy.py new file mode 100644 index 0000000..b8161ec --- /dev/null +++ b/cubes/runtime/docker/deploy.py @@ -0,0 +1,75 @@ +from pyinfra import host +from pyinfra.operations import server, apt + + +DISTRO = host.data.DISTRO + +server.shell( + commands=[ + "install -m 0755 -d /etc/apt/keyrings", + f"curl -fsSL https://download.docker.com/linux/{DISTRO}/gpg | gpg --dearmor --yes -o /etc/apt/keyrings/docker.gpg", + "sudo chmod a+r /etc/apt/keyrings/docker.gpg", + f""" echo "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/{DISTRO} "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null """, + "apt update" + ], + _sudo=True +) + +apt.packages( + packages=[ + "docker-ce", + "docker-ce-cli", + "containerd.io", + "docker-buildx-plugin", + "docker-compose-plugin", + ], + present=True, + _sudo=True +) + + +server.shell( + name="Install Lazydocker system-wide", + commands=[ + 'export DIR=/usr/local/bin && curl -fsSL https://raw.githubusercontent.com/jesseduffield/lazydocker/master/scripts/install_update_linux.sh | bash -s --' + ], + _sudo=True +) + +# Install required packages +apt.packages( + name="Install required dependencies", + packages=["ca-certificates", "curl", "gnupg", "lsb-release"], + update=True, + _sudo=True +) + +# Download and store the GPG key +server.shell( + name="Download and store Azlux repo GPG key", + commands=[ + "curl -fsSL https://azlux.fr/repo.gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/azlux-archive-keyring.gpg" + ], + _sudo=True +) + +# Add the repository to sources.list +server.shell( + name="Add Azlux repository to APT sources", + commands=[ + """ echo "deb [arch="$(dpkg --print-architecture)" signed-by=/usr/share/keyrings/azlux-archive-keyring.gpg] http://packages.azlux.fr/debian bookworm main" | sudo tee /etc/apt/sources.list.d/azlux.list > /dev/null """, + ], + _sudo=True +) + +# Update APT and install docker-ctop +apt.update( + name="Update package lists", + _sudo=True +) + +apt.packages( + name="Install docker-ctop", + packages=["docker-ctop"], + _sudo=True +) \ No newline at end of file diff --git a/cubes/runtime/docker/manifest.mjs b/cubes/runtime/docker/manifest.mjs new file mode 100644 index 0000000..a0ae776 --- /dev/null +++ b/cubes/runtime/docker/manifest.mjs @@ -0,0 +1,11 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'runtime:docker', + name: 'Install docker and tools', + dependencies: () => [], + schema: z.object({ + DISTRO: z.enum(['ubuntu', 'debian']).default('ubuntu').describe('Linux distribution to target'), + }), +}); diff --git a/cubes/runtime/nodevm/README.md b/cubes/runtime/nodevm/README.md new file mode 100644 index 0000000..8f75a87 --- /dev/null +++ b/cubes/runtime/nodevm/README.md @@ -0,0 +1,83 @@ +# nodevm + +**Install Node.js with essential global packages** + +## Purpose + +This cube installs the latest LTS (Long Term Support) version of Node.js along with essential global npm packages commonly needed for development and deployment. + +## What is Node.js? + +Node.js is a JavaScript runtime built on Chrome's V8 engine that allows you to run JavaScript on the server. It's widely used for: + +- Building web servers and APIs +- Command-line tools +- Build tools and task runners +- Real-time applications (chat, notifications) +- Microservices + +## What This Cube Does + +1. **Installs Node.js LTS** + - Downloads and runs the official NodeSource setup script + - Installs the latest LTS version of Node.js + - Includes npm (Node Package Manager) + +2. **Installs build dependencies** + - `libssl-dev` - SSL/TLS libraries + - `libtool` - Library building tools + - `cmake` - Cross-platform build system + - `libpng-dev`, `libjpeg-dev`, `libvips-dev` - Image processing libraries + +3. **Installs global npm packages** + - **npm@11.1.0** - Latest npm version + - **pm2** - Production process manager for Node.js apps + - **yarn** - Alternative package manager + - **local-web-server** - Local development web server + - **node-gyp** - Node.js native addon build tool + - **inquirer** - Interactive command-line prompts + - **execa** - Better child process execution + - **@dotenvx/dotenvx** - Environment variable management + +## Configuration + +This cube currently has no configurable parameters. + +## Dependencies + +None - this cube can run standalone. + +## Post-Installation + +Verify installation: +```bash +node --version +npm --version +``` + +Common commands: +- Run a Node.js app: `node app.js` +- Start with PM2: `pm2 start app.js` +- Install packages: `npm install ` +- Use yarn: `yarn add ` + +## PM2 - Process Manager + +PM2 is included for production deployments. Common PM2 commands: + +```bash +pm2 start app.js # Start application +pm2 list # List running apps +pm2 stop app # Stop application +pm2 restart app # Restart application +pm2 logs # View logs +pm2 startup # Enable PM2 on boot +pm2 save # Save current process list +``` + +## Notes + +- Node.js is installed system-wide +- Global packages are accessible to all users +- npm cache is stored in `~/.npm` +- Use `nvm` if you need multiple Node.js versions diff --git a/cubes/runtime/nodevm/deploy.py b/cubes/runtime/nodevm/deploy.py new file mode 100644 index 0000000..93c513a --- /dev/null +++ b/cubes/runtime/nodevm/deploy.py @@ -0,0 +1,58 @@ +from pyinfra.operations import server, apt, npm, python +from pyinfra import host +from pyinfra.facts.files import Directory +from pyinfra.facts.server import Which + +hasNode = host.get_fact(Which, 'node') +VERSION = host.data.VERSION +ALIAS = host.data.ALIAS +GLOBAL_PACKAGES = host.data.GLOBAL_PACKAGES +USER = host.data.USER + +apt.packages( + name=f'Install nodejs tools', + no_recommends=True, + packages=[ + 'build-essential', + 'libssl-dev', + 'libtool', + 'cmake', + 'libcairo2-dev', + 'libpango1.0-de', + 'libpng-dev', + 'libgif-dev', + 'libjpeg-dev', + 'libvips-dev', + 'librsvg2-dev', + 'libpixman-1-dev', + ], + _sudo = True, + ) + +server.shell( + commands=[ + "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash", + "omf install nvm", + f"nvm install {VERSION}", + f"nvm alias {ALIAS} {VERSION}", + "set -gx NVM_DIR $HOME/.nvm", + ], + _sudo=True, + _su_user=USER, + _use_su_login=True, + _shell_executable='/usr/bin/fish' +) + + +server.shell( + commands=[ + "npm install -g pm2 yarn local-web-server node-gyp inquirer execa @dotenvx/dotenvx" + ], + _sudo=True, + _su_user=USER, + _use_su_login=True, + _shell_executable='/usr/bin/fish' + +) + + diff --git a/cubes/runtime/nodevm/manifest.mjs b/cubes/runtime/nodevm/manifest.mjs new file mode 100644 index 0000000..ed3584a --- /dev/null +++ b/cubes/runtime/nodevm/manifest.mjs @@ -0,0 +1,20 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'runtime:nodevm', + name: 'Install nvm and nodejs with global packages', + dependencies: () => [], + schema: z.object({ + VERSION: z + .nullable(z.string()) + .describe('Node.js version to install. It is recommended to use semver notation') + .default('v22.20.0'), + USER: z.string().describe('Username for which to install nodejs').default('vagrant'), + ALIAS: z.string().describe('The alias for this node version').default('nodelts'), + GLOBAL_PACKAGES: z + .string() + .describe('Space-separated list of global npm packages to install') + .default('npm-check-updates'), + }), +}); diff --git a/cubes/service/autostart/README.md b/cubes/service/autostart/README.md new file mode 100644 index 0000000..3791986 --- /dev/null +++ b/cubes/service/autostart/README.md @@ -0,0 +1,176 @@ +# TypeStack Install Cube + +Deploys a Node.js/TypeScript application from a Git repository as a systemd service with Docker Compose and PM2 support. + +## Features + +- Clones Git repository +- Installs dependencies with Yarn +- Builds the application +- Starts Docker Compose services +- Creates a systemd service for automatic startup +- Configures PM2 for process management +- Automatic restart on failure + +## Requirements + +- Git (for cloning repository) +- Yarn (for dependency management) +- Docker and Docker Compose +- PM2 (for process management) +- Node.js/NVM installed +- SSH key access to the repository (if using private repos) + +## Configuration Parameters + +### Required + +- **USER**: System user to run the application (default: `teclabmin`) +- **REPO**: Git repository URL (default: `git@github.com:bennidi/teclab-flintstone.git`) +- **APP**: Application name/directory name (default: `flintstone`) + +### Optional + +- **ENV**: Application environment (default: `production`) +- **AUTOSTART**: Enable and start service immediately (default: `True`) +- **NODE_PATH**: Path to Node.js binaries (default: `/home/teclabmin/.nvm/versions/node/v21.7.3/bin`) + +## Example Usage + +### Basic Configuration + +```json +{ + "USER": "myuser", + "REPO": "git@github.com:myorg/myapp.git", + "APP": "myapp" +} +``` + +### Advanced Configuration + +```json +{ + "USER": "appuser", + "REPO": "git@github.com:myorg/myapp.git", + "APP": "myapp", + "ENV": "staging", + "AUTOSTART": false, + "NODE_PATH": "/home/appuser/.nvm/versions/node/v20.0.0/bin" +} +``` + +## What This Cube Does + +1. **Clone Repository**: Clones the specified Git repository to `/home//` +2. **Install Dependencies**: Runs `yarn install` to install all dependencies +3. **Build Application**: Runs `yarn build` to compile the application +4. **Start Docker Services**: Runs `docker compose up -d` to start containerized services +5. **Create Startup Script**: Creates `/home//.service.sh` that: + - Starts Docker Compose services + - Starts PM2 with ecosystem.config.js +6. **Create Systemd Service**: Creates `/etc/systemd/system/.service` that: + - Runs after Docker service + - Uses the specified user + - Configures proper environment (HOME, PATH) + - Auto-restarts on failure +7. **Enable & Start Service**: Enables and starts the service (if AUTOSTART=True) + +## Service Management + +### Check service status + +```bash +sudo systemctl status +``` + +### Start the service + +```bash +sudo systemctl start +``` + +### Stop the service + +```bash +sudo systemctl stop +``` + +### Restart the service + +```bash +sudo systemctl restart +``` + +### View service logs + +```bash +sudo journalctl -u -f +``` + +### Disable autostart + +```bash +sudo systemctl disable +``` + +## File Structure + +After deployment: + +``` +/home// +├── / # Application directory +│ ├── ecosystem.config.js # PM2 configuration +│ ├── docker-compose.yml # Docker services +│ └── ... # Application files +├── .service.sh # Startup script +/etc/systemd/system/ +└── .service # Systemd service file +``` + +## Troubleshooting + +### Service fails to start + +1. Check service logs: + ```bash + sudo journalctl -u -n 50 + ``` + +2. Verify Docker is running: + ```bash + sudo systemctl status docker + ``` + +3. Check if Node.js path is correct: + ```bash + which node + which pm2 + ``` + +### Repository clone fails + +- Ensure SSH keys are properly configured for the user +- Test SSH access: `ssh -T git@github.com` +- Check repository URL is correct + +### Docker Compose fails + +- Verify Docker is installed and running +- Check docker-compose.yml exists in the application directory +- Ensure user has Docker permissions: `sudo usermod -aG docker ` + +### PM2 not starting + +- Verify PM2 is installed: `pm2 --version` +- Check ecosystem.config.js exists +- Verify NODE_PATH includes PM2 binary location + +## Notes + +- The service type is set to `forking` to support PM2's daemon mode +- Service will auto-restart on failure with a 5-second delay +- Maximum 5 restart attempts in the burst period +- The service waits for Docker to be ready before starting +- Environment variables can be configured in the ecosystem.config.js file diff --git a/cubes/service/autostart/deploy.py b/cubes/service/autostart/deploy.py new file mode 100644 index 0000000..e6f7f32 --- /dev/null +++ b/cubes/service/autostart/deploy.py @@ -0,0 +1,27 @@ +from pyinfra.operations import systemd +from pyinfra import host + + +APP = host.data.APP + +# Enable and start the service based on AUTOSTART flag +if AUTOSTART: + systemd.service( + name=f'Enable {SERVICE_NAME} service', + service=APP, + enabled=True, + _sudo=True + ) + + systemd.service( + name=f'Start {SERVICE_NAME} service', + service=APP, + running=True, + _sudo=True + ) +else: + server.shell( + name=f'Service {SERVICE_NAME} created but not enabled (AUTOSTART=False)', + commands=[f'echo "Service {SERVICE_NAME} is ready but not started. Enable with: sudo systemctl enable {APP} && sudo systemctl start {APP}"'], + _sudo=False + ) \ No newline at end of file diff --git a/cubes/service/autostart/manifest.mjs b/cubes/service/autostart/manifest.mjs new file mode 100644 index 0000000..863b3fd --- /dev/null +++ b/cubes/service/autostart/manifest.mjs @@ -0,0 +1,13 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'service:autostart', + name: 'Manage systemd service autostart', + dependencies: () => [], + schema: z.object({ + APP: z.string().describe('The name of the systemd service (e.g., flintstone)'), + SERVICE_NAME: z.string().optional().describe('Display name for the service').default('Application'), + AUTOSTART: z.boolean().describe('Should the service be enabled and started?').default(true), + }), +}); diff --git a/cubes/ssh/authorize/README.md b/cubes/ssh/authorize/README.md new file mode 100644 index 0000000..d5e3d4d --- /dev/null +++ b/cubes/ssh/authorize/README.md @@ -0,0 +1,32 @@ +# ssh-authorize + +**Authorize SSH public key for a user** + +## Purpose + +This cube adds a specific SSH public key to a user's `authorized_keys` file on the remote server, allowing them to log in via SSH using the corresponding private key. + +## Configuration + +### Parameters + +- **USER** (string, default: `'vagrant'`) + - The username on the remote server to authorize. + - If the user does not exist, `pyinfra` will attempt to create it (though a full user creation with shell/groups is better handled by `user-add`). + +- **PUBKEY** (string, required) + - The actual content of the public key (e.g., `ssh-ed25519 AAAA...`). + +## Use Cases + +Grant access to a developer: +```javascript +exec('ssh-authorize', { + USER: 'developer', + PUBKEY: 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...' +}) +``` + +## Dependencies + +None. diff --git a/cubes/ssh/authorize/deploy.py b/cubes/ssh/authorize/deploy.py new file mode 100644 index 0000000..aef142e --- /dev/null +++ b/cubes/ssh/authorize/deploy.py @@ -0,0 +1,13 @@ +from pyinfra import host +from pyinfra.operations import server + +USER = host.data.USER +PUBKEY = host.data.PUBKEY + +server.user( + name=f"Authorize public key for {USER}", + user=USER, + public_keys=[PUBKEY], + present=True, + _sudo=True +) diff --git a/cubes/ssh/authorize/manifest.mjs b/cubes/ssh/authorize/manifest.mjs new file mode 100644 index 0000000..8d2eb08 --- /dev/null +++ b/cubes/ssh/authorize/manifest.mjs @@ -0,0 +1,12 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'ssh:authorize', + name: 'Authorize SSH public key for a user', + dependencies: () => [], + schema: z.object({ + USER: z.string().describe('Username to authorize').default('vagrant'), + PUBKEY: z.string().describe('SSH public key string').default(''), + }), +}); diff --git a/cubes/ssh/keygen/README.md b/cubes/ssh/keygen/README.md new file mode 100644 index 0000000..23eb4db --- /dev/null +++ b/cubes/ssh/keygen/README.md @@ -0,0 +1,93 @@ +# ssh-keygen + +**Generate SSH key for a given user** + +## Purpose + +This cube generates a new SSH key pair for a specified user, which can be used for secure, passwordless authentication to remote servers and services like GitHub, GitLab, or other SSH-accessible systems. + +## What are SSH Keys? + +SSH keys provide a more secure and convenient way to authenticate compared to passwords: + +- **Public key**: Shared with servers/services you want to access (like GitHub) +- **Private key**: Kept secret on your local machine, never shared +- **Passphrase-free**: This cube generates keys without a passphrase for automation +- **Algorithm support**: RSA (traditional) or Ed25519 (modern, recommended) + +## What This Cube Does + +1. Creates the `.ssh` directory with proper permissions (700) +2. Generates an SSH key pair using the specified algorithm +3. Saves the keys as `id_{SUFFIX}` and `id_{SUFFIX}.pub` +4. Logs the public key to the console for easy copying + +## Configuration + +### Parameters + +- **SUFFIX** (string, default: `'ed25519'`) + - Suffix for keyname (e.g., `github` → `id_github.pub`) + - Helps identify the purpose of the key + +- **EMAIL** (string, default: `'undefined@bitsquare.dev'`) + - Email address to associate with the SSH key + - Used as a comment in the public key + +- **ALGORITHM** (enum: `'rsa'` | `'ed25519'`, default: `'ed25519'`) + - SSH key algorithm type + - **Ed25519**: Modern, faster, more secure (recommended) + - **RSA**: Traditional, widely supported + +- **USER** (string, default: `'vagrant'`) + - Username for which to generate the SSH key + - Inherited from `user-add` dependency + +## Dependencies + +- **user-add** - Creates the user account first + +## Post-Installation + +After the key is generated: + +1. The public key will be logged to the console +2. Copy the public key and add it to the target service: + - **GitHub**: Settings → SSH and GPG keys → New SSH key + - **GitLab**: Preferences → SSH Keys + - **Remote server**: Add to `~/.ssh/authorized_keys` + +3. SSH config is automatically set up for the key + +## Key Locations + +- Private key: `/home/{USER}/.ssh/id_{SUFFIX}` +- Public key: `/home/{USER}/.ssh/id_{SUFFIX}.pub` + +## Algorithm Comparison + +**Ed25519** (Recommended): +- Smaller keys (256-bit) +- Faster generation and verification +- More secure against certain attacks +- Not supported on very old systems + +**RSA**: +- Larger keys (2048-4096 bit) +- Universally supported +- Slower than Ed25519 +- Well-tested and trusted + +## Example Usage + +Generate a key for GitHub access: +```javascript +exec('ssh-keygen', { + SUFFIX: 'github', + EMAIL: 'myemail@example.com', + ALGORITHM: 'ed25519', + USER: 'myuser' +}) +``` + +This creates `id_github` and `id_github.pub` in `/home/myuser/.ssh/`. diff --git a/cubes/ssh/keygen/deploy.py b/cubes/ssh/keygen/deploy.py new file mode 100644 index 0000000..4beb029 --- /dev/null +++ b/cubes/ssh/keygen/deploy.py @@ -0,0 +1,46 @@ +from pyinfra.operations import files, server, python +from pyinfra import host, logger +import logging + + +NAME=host.data.SUFFIX +EMAIL=host.data.EMAIL +ALGORITHM=host.data.ALGORITHM +USER=host.data.USER + +# Ensure the .ssh directory exists +files.directory( + name="Ensure .ssh directory exists", + path=f"/home/{USER}/.ssh", + present=True, + mode=700, + user=USER, + group=USER, +) + +# Generate the SSH keypair +server.shell( + name=f"Generate SSH key id_{NAME}", + commands=[ + f"ssh-keygen -t {ALGORITHM} -f /home/{USER}/.ssh/id_{NAME} -C '{EMAIL}' -N ''" + ] +) + +# Print the public key +result = server.shell( + name="Print public key", + commands=[f"cat /home/{USER}/.ssh/id_{NAME}.pub"], +) + +def callback(): + # 🔹 Extract and log the key output + if result.stdout: + logger.info(f"Public Key for {USER}: {result.stdout.strip()}") + else: + logger.warning(f"No public key found for {USER} at /home/{USER}/.ssh/id_{NAME}.pub") + +python.call( + name="Log public key", + function=callback, +) + diff --git a/cubes/ssh/keygen/manifest.mjs b/cubes/ssh/keygen/manifest.mjs new file mode 100644 index 0000000..a14bba8 --- /dev/null +++ b/cubes/ssh/keygen/manifest.mjs @@ -0,0 +1,20 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'ssh:keygen', + name: 'Generate SSH key for a given $USER', + dependencies: () => ['user:add'], + schema: z.object({ + SUFFIX: z + .string() + .describe('Suffix for keyname, e.g. github => id_github.pub') + .default('ed25519'), + EMAIL: z + .string() + .describe('Email address to associate with the SSH key') + .default('undefined@bitsquare.dev'), + ALGORITHM: z.enum(['rsa', 'ed25519']).describe('SSH key algorithm type').default('ed25519'), + USER: z.string().describe('Username for which to generate the SSH key').default('vagrant'), + }), +}); diff --git a/cubes/ssh/keyman/README.md b/cubes/ssh/keyman/README.md new file mode 100644 index 0000000..3379f53 --- /dev/null +++ b/cubes/ssh/keyman/README.md @@ -0,0 +1,110 @@ +# ssh-keyman + +**Deploy existing SSH keys to host** + +## Purpose + +This cube deploys pre-existing SSH key pairs from your local machine to a remote server, configuring them for automatic use with specified hosts (like GitHub, GitLab, etc.). + +## What This Cube Does + +1. **Copies SSH keys to the server** + - Transfers both private and public keys from local directory to remote `.ssh` folder + - Sets correct file permissions (600 for private, 644 for public) + +2. **Configures SSH client** + - Creates/updates `.ssh/config` to use the deployed key for specified hosts + - Disables strict host key checking for easier automation + - Maps each host to use the correct identity file + +3. **Ensures security** + - Sets proper directory permissions (700 for `.ssh`) + - Ensures keys are owned by the specified user + +## Configuration + +### Parameters + +- **KEY_NAME** (string, default: `'id_ed25519'`) + - Name of the SSH key file (without extension) + - Must exist in the KEY_DIR directory locally + +- **USER** (string, default: `'vagrant'`) + - Username for which to deploy the SSH key + - Inherited from `user-add` dependency + +- **HOSTS** (string, default: `'github.com'`) + - Space-separated list of hosts to add to known_hosts + - Example: `'github.com gitlab.com bitbucket.org'` + +## Dependencies + +- **user-add** - Creates the user account first + +## Use Cases + +Deploy GitHub SSH key: + +```javascript +exec('ssh-keyman', { + KEY_NAME: 'id_github', + HOSTS: 'github.com' +}) +``` + +Deploy key for multiple Git services: + +```javascript +exec('ssh-keyman', { + KEY_NAME: 'id_git', + HOSTS: 'github.com gitlab.com bitbucket.org' +}) +``` + +## What Gets Configured + +After deployment, the `.ssh/config` file will contain entries like: + +``` +Host github.com + IdentityFile /home/{USER}/.ssh/id_github + StrictHostKeyChecking no +``` + +This means when you run `git clone git@github.com:user/repo.git`, it will automatically use the deployed key. + +## Key File Requirements + +The local KEY_DIR must contain: + +- `{KEY_NAME}` - Private key file +- `{KEY_NAME}.pub` - Public key file + +For example, if `KEY_NAME=id_github`, you need: + +- `./vault/tmp/id_github` +- `./vault/tmp/id_github.pub` + +## Security Considerations + +- **Private keys are sensitive**: Ensure your local KEY_DIR is secure +- **StrictHostKeyChecking disabled**: Convenient but less secure + - Consider enabling it for production: Edit `/home/{USER}/.ssh/config` +- **Backup your keys**: Keep secure copies of private keys +- **Use different keys**: Consider separate keys for different services + +## Post-Installation + +Test SSH connection: + +```bash +ssh -T git@github.com +# Should show: "Hi username! You've successfully authenticated..." +``` + +Clone a repository: + +```bash +git clone git@github.com:user/repo.git +# Should work without prompting for credentials +``` diff --git a/cubes/ssh/keyman/deploy.py b/cubes/ssh/keyman/deploy.py new file mode 100644 index 0000000..7955da1 --- /dev/null +++ b/cubes/ssh/keyman/deploy.py @@ -0,0 +1,96 @@ +from pyinfra.operations import server, files, apt, systemd +from pyinfra import host +import subprocess +import json + + +def get_keyman_config(): + """Get keyman configuration by calling keyman --print-config""" + try: + result = subprocess.run( + ['keyman', '--print-config'], + capture_output=True, + text=True, + check=True + ) + return json.loads(result.stdout) + except (subprocess.CalledProcessError, json.JSONDecodeError, FileNotFoundError): + return None + + +# Load keyman config for defaults +keyman_config = get_keyman_config() + +USER = host.data.USER +KEY = host.data.KEY_NAME + +# Use KEY_DIR from host data, or fall back to keyman config tmpDir +DIR = host.data.get('KEY_DIR') +if not DIR and keyman_config: + DIR = keyman_config.get('tmpDir') +if not DIR: + DIR = '../../vault/tmp' # Final fallback + +# Support multiple hosts separated by space +HOSTS = map(str.lstrip, str(host.data.HOSTS).split(' ')) + +# 🔹 Define remote paths +SSH_DIR = f"/home/{USER}/.ssh" if USER != "root" else "/root/.ssh" +PRIVATE_KEY_PATH = f"{SSH_DIR}/{KEY}" +PUBLIC_KEY_PATH = f"{SSH_DIR}/{KEY}.pub" + +# Ensure the .ssh directory exists +files.directory( + name="Ensure .ssh directory exists", + path=SSH_DIR, + present=True, + mode=700, + user=USER, + group=USER, + _sudo=True +) +# Copy private key to the remote server +files.put( + name="Copy private key", + src=f"{DIR}/{KEY}", + dest=PRIVATE_KEY_PATH, + mode="600", + user=USER, + group=USER, + _sudo=True, +) + +# Copy public key to the remote server +files.put( + name="Copy public key", + src=f"{DIR}/{KEY}.pub", + dest=PUBLIC_KEY_PATH, + mode="644", + user=USER, + group=USER, + _sudo=True, +) + +# Ensure correct permissions for the private key +server.shell( + name="Set correct permissions for private key", + commands=[f"chmod 600 {PRIVATE_KEY_PATH}"], + _sudo=True, +) + +files.file( + name="Ensure .ssh/config directory exists", + path=f"/home/{USER}/.ssh/config", + present=True, + user=USER, + group=USER, + _sudo=True +) + +for host in HOSTS: + files.line( + name=f"Configure SSH key for {host}", + path=f"{SSH_DIR}/config", + line=f"Host {host}\n IdentityFile {SSH_DIR}/{KEY}\n StrictHostKeyChecking no", + _sudo=True +) \ No newline at end of file diff --git a/cubes/ssh/keyman/manifest.mjs b/cubes/ssh/keyman/manifest.mjs new file mode 100644 index 0000000..cb69207 --- /dev/null +++ b/cubes/ssh/keyman/manifest.mjs @@ -0,0 +1,19 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'ssh:keyman', + name: 'Deploy an ssh key managed by keyman', + dependencies: () => [], + schema: z.object({ + KEY_NAME: z + .string() + .describe('Name of the SSH key file (without extension)') + .default('id_ed25519'), + USER: z.string().describe('Username for which to deploy the SSH key').default('vagrant'), + HOSTS: z + .string() + .describe('Space-separated list of hosts to add to known_hosts') + .default('github.com'), + }), +}); diff --git a/cubes/user/add/README.md b/cubes/user/add/README.md new file mode 100644 index 0000000..925d810 --- /dev/null +++ b/cubes/user/add/README.md @@ -0,0 +1,130 @@ +# user-add + +**Add a user with Fish shell and tools** + +## Purpose + +This cube creates a new user account with a modern shell environment (Fish), SSH key authentication, and enhanced productivity tools pre-configured. + +## What This Cube Does + +1. **Creates a new user account** + - Sets up home directory with proper permissions + - Configures password authentication + - Adds user to specified groups (e.g., `docker`, `sudo`) + - Sets Fish as the default login shell + +2. **Configures SSH access** + - Deploys the specified SSH public key for passwordless authentication + - Creates `.ssh` directory with proper permissions + - Sets up SSH config file + - Configures SSH agent auto-loading for Fish shell + +3. **Installs Fish shell enhancements** + - Installs **Oh My Fish** (OMF) - Fish shell framework with themes and plugins + - Deploys custom Fish configuration (`config.fish`) + - Sets up Fish rc directory for modular configurations + +4. **Creates workspace directories** + - Creates `/home/{USER}/tmp` directory for temporary files + +## Configuration + +### Parameters + +- **USER** (string, auto-generated) + - Username for the new user account + - Default: `userXXXXX` (randomly generated 5-character suffix) + +- **PASSWORD** (string, auto-generated) + - Password for the new user account + - Default: randomly generated secure password + +- **GROUPS** (string, default: `''`) + - Comma-separated list of additional groups (e.g., `"docker,sudo"`) + - Common groups: + - `docker` - Run Docker without sudo + - `sudo` - Administrative privileges + - `www-data` - Web server file access + +- **PUBKEY** (string, has default) + - SSH public key to authorize for the user + - Should be your public key for passwordless SSH access + +## Dependencies + +- **apt:essentials** - Provides Fish shell and basic tools + +## What is Fish? + +Fish (Friendly Interactive Shell) is a modern command-line shell that focuses on usability: + +- **Smart autosuggestions**: Suggests commands as you type based on history +- **Syntax highlighting**: Color-codes commands in real-time +- **Tab completions**: Comprehensive, discoverable command completions +- **No configuration needed**: Works great out of the box + +## Post-Installation + +After deployment: +- SSH into the server as the new user: `ssh {USER}@server` +- Your SSH key will be pre-authorized (no password needed if using key) +- Fish shell will start automatically with OMF installed +- SSH agent auto-loads to manage your SSH keys + +## Notes + +- The user's home directory is created at `/home/{USER}` +- Fish configuration is stored in `/home/{USER}/.config/fish/` +- Oh My Fish provides package management: `omf install ` +- To switch shells: `chsh -s /bin/bash` (or back to fish: `chsh -s /usr/bin/fish`) + +--- + +# 📌 Most Useful Fish Key Bindings (with Fisher Extensions) + +## 🐟 Default Fish Key Bindings + +- `Ctrl + C` → Cancel the current command +- `Ctrl + D` → Exit the shell (or logout if in SSH) +- `Ctrl + L` → Clear the terminal +- `Ctrl + R` → Search command history (enhanced by `fzf.fish`) +- `Ctrl + U` → Delete the entire command line +- `Ctrl + W` → Delete the last word +- `Alt + ← / →` → Move backward/forward by a word + +## 🔍 Enhanced with `fzf.fish` + +- `Ctrl + R` → **Fuzzy search command history** +- `Ctrl + T` → **Fuzzy search and insert file path** +- `Alt + C` → **Fuzzy search directories (`cd` with `z`)** + +## 📂 Directory Navigation (with `z`) + +- `z ` → Jump to a frequently used directory +- `z -l` → List most-used directories +- `z -c` → Remove a directory from `z`'s database + +## 🔄 Process & Job Management + +- `Ctrl + Z` → Suspend the current process +- `fg` → Bring a suspended process back to foreground +- `jobs` → List background jobs + +## 🎨 Other Handy Shortcuts + +- `fish_vi_key_bindings` → Enable Vi mode (press `Esc` for normal mode) +- `Ctrl + G` → Show Git status (if using `fzf.fish`) +- `Ctrl + E` → Edit command line in `$EDITOR` + +## ⚙️ Useful Commands for Key Binding + +```fish +# Set Fish default key bindings +fish_default_key_bindings + +# Enable Vi mode +fish_vi_key_bindings + +# Rebind a custom key (Example: Ctrl + G for git status) +bind \cg 'git status' diff --git a/cubes/user/add/config.fish b/cubes/user/add/config.fish new file mode 100644 index 0000000..167b7b5 --- /dev/null +++ b/cubes/user/add/config.fish @@ -0,0 +1,10 @@ +if status is-interactive + # Commands to run in interactive sessions can go here + # Execute all scripts in ~/.config/fish/rc/ on shell startup + for script in ~/.config/fish/rc/*.fish + if test -f $script + source $script + end + end +end + diff --git a/cubes/user/add/deploy.py b/cubes/user/add/deploy.py new file mode 100644 index 0000000..5d4602c --- /dev/null +++ b/cubes/user/add/deploy.py @@ -0,0 +1,90 @@ +from pyinfra import host +from pyinfra.operations import server, files, apt +from io import StringIO + +# Define the username, password, and public key for the new admin user +USER = host.data.USER +HOME_DIR = f"/home/{USER}" +TMP_DIR = f"{HOME_DIR}/tmp" +PASSWORD = host.data.PASSWORD +PUBKEY = host.data.PUBKEY +GROUPS = list(filter(None, map(str.strip, str(host.data.GROUPS).split()))) +FISH_PATH = "/usr/bin/fish" +FISH_CONFIG_DIR = f"{HOME_DIR}/.config/fish" +FISH_CONFIG_FILE = f"{FISH_CONFIG_DIR}/config.fish" +FISH_RC_DIR = f"{FISH_CONFIG_DIR}/rc" +SSH_AGENT_SCRIPT = f"{FISH_RC_DIR}/ssh-agent.fish" + +apt.packages( + name='Ensure fish shell is installed', + packages=[ 'fish'], + _sudo=True +) + + +# Ensure the user exists with a login shell +server.user( + name=f"Create user {USER} [{GROUPS}]", + present=True, + user=USER, + password=PASSWORD, + create_home=True, + groups=GROUPS, + shell=FISH_PATH, + public_keys=[PUBKEY], + _sudo=True +) + +for dir in [f"{HOME_DIR}/.ssh", FISH_RC_DIR, TMP_DIR]: + files.directory( + name=f"Ensure {dir} directory exists", + path=dir, + present=True, + mode=700, + user=USER, + group=USER, + _sudo=True, + _sudo_user=USER, + _use_sudo_login=True + ) + +files.file( + name="Ensure .ssh/config exists", + path=f"{HOME_DIR}/.ssh/config", + present=True, + user=USER, + group=USER, + _sudo=True +) + +server.shell( + name=f"Install OMF(Oh My Fish) for {USER}", + commands=[ + f"curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install > install-omf", + f"fish install-omf --yes --noninteractive", + ], + _sudo=True, + _sudo_user=USER, + _use_sudo_login=True +) + +files.put( + name="Add SSH agent auto-load script to Fish rc directory", + src="ssh-agent.fish", + dest=SSH_AGENT_SCRIPT, + user=USER, + group=USER, + mode="755", # Make it executable + _sudo=True, + +) + +files.put( + name="Add custom config.fish", + src="config.fish", + dest=FISH_CONFIG_FILE, + user=USER, + group=USER, + mode="755", # Make it executable + _sudo=True, +) \ No newline at end of file diff --git a/cubes/user/add/manifest.mjs b/cubes/user/add/manifest.mjs new file mode 100644 index 0000000..81d3c68 --- /dev/null +++ b/cubes/user/add/manifest.mjs @@ -0,0 +1,25 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + id: 'user:add', + name: 'Add a user with fish shell and tools', + dependencies: () => ['apt:essentials'], + schema: z.object({ + USER: z + .string() + .describe('Username for the new user account') + .default(() => `user${cubes.uniqid(5)}`), + PASSWORD: z.string().describe('Password for the new user account').default(cubes.uniqid), + GROUPS: z + .string() + .describe('Comma-separated list of additional groups (e.g., "docker,sudo")') + .default(''), + PUBKEY: z + .string() + .describe('SSH public key to authorize for the user') + .default( + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan' + ), + }), +}); diff --git a/cubes/user/add/ssh-agent.fish b/cubes/user/add/ssh-agent.fish new file mode 100644 index 0000000..a96b131 --- /dev/null +++ b/cubes/user/add/ssh-agent.fish @@ -0,0 +1,14 @@ +# Start SSH agent if not already running +if not set -q SSH_AUTH_SOCK + eval (ssh-agent -c) +end + +# Add all private SSH keys in ~/.ssh to the agent +for key in ~/.ssh/id_*; + if test -f $key; and not string match -q "*pub" $key + ssh-add $key 2>/dev/null + end +end +# Export user and group ID for Docker +set -x UID (id -u) +set -x GID (id -g) \ No newline at end of file diff --git a/cubes/user/edit/README.md b/cubes/user/edit/README.md new file mode 100644 index 0000000..6599e66 --- /dev/null +++ b/cubes/user/edit/README.md @@ -0,0 +1,52 @@ +# user:edit + +**Modify an existing user's password or group membership** + +## Purpose + +This cube allows you to update existing user accounts on the target system. It can be used to change passwords, add users to new groups (like `docker` or `sudo`), or revoke group memberships. + +## What This Cube Does + +1. Identifies the existing user on the target system +2. Updates the user's password if `PASSWORD` is provided +3. Adds the user to the groups specified in `GROUPS` +4. Removes the user from the groups specified in `GROUPS_ABSENT` + +## Configuration + +| Variable | Type | Description | Required | +| :--- | :--- | :--- | :--- | +| `USER` | `string` | The username of the account to modify | Yes | +| `PASSWORD` | `string` | New password for the user | No | +| `GROUPS` | `string` | Comma-separated list of groups to ADD (e.g., `docker,sudo`) | No | +| `GROUPS_ABSENT` | `string` | Comma-separated list of groups to REMOVE | No | + +## Dependencies + +- `apt/essentials`: Standard system utilities. + +## Usage + +### Changing a Password + +```bash +nopy install user:edit --env USER=myuser --env PASSWORD=newsecurepassword +``` + +### Adding a User to the Docker Group + +```bash +nopy install user:edit --env USER=myuser --env GROUPS=docker +``` + +### Revoking Sudo Access + +```bash +nopy install user:edit --env USER=myuser --env GROUPS_ABSENT=sudo +``` + +## Security Notes + +- When setting passwords via the CLI, they may be visible in your shell history. Consider using a session file or interactive prompts for sensitive values. +- Changing your own user's groups or password may require a re-login to take full effect. diff --git a/cubes/user/edit/deploy.py b/cubes/user/edit/deploy.py new file mode 100644 index 0000000..32338b3 --- /dev/null +++ b/cubes/user/edit/deploy.py @@ -0,0 +1,24 @@ +from pyinfra import host +from pyinfra.operations import server + +# [agnt://cogen/cogen/user-edit-2]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} + +""" +Deployment script for user:edit. +Updates password and group membership for an existing user. +""" + +USER = host.data.USER +PASSWORD = host.data.get('PASSWORD') +GROUPS = [g.strip() for g in str(host.data.get('GROUPS', '')).split(',') if g.strip()] +GROUPS_ABSENT = [g.strip() for g in str(host.data.get('GROUPS_ABSENT', '')).split(',') if g.strip()] + +# Update user details +server.user( + name=f"Update user {USER}", + user=USER, + password=PASSWORD, + groups=GROUPS, + groups_absent=GROUPS_ABSENT, + _sudo=True, +) diff --git a/cubes/user/edit/manifest.mjs b/cubes/user/edit/manifest.mjs new file mode 100644 index 0000000..fd7f342 --- /dev/null +++ b/cubes/user/edit/manifest.mjs @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { cubes } from '@bitstack/nopy'; + +// [agnt://cogen/cogen/user-edit-1]{cartridge: "ansiblings/cubes", action: "generated", status: "generated"} + +/** + * Manifest for the user:edit cube. + * Allows modifying existing user accounts (password, groups). + */ +export default cubes.Manifest({ + id: 'user:edit', + name: 'user:edit - Modify an existing user account', + dependencies: () => [], + schema: z.object({ + USER: z.string().describe('The username of the account to modify'), + PASSWORD: z.string().optional().describe('New password for the user (optional)'), + GROUPS: z.string().optional().describe('Comma-separated list of groups the user SHOULD be in (optional)'), + GROUPS_ABSENT: z.string().optional().describe('Comma-separated list of groups to REMOVE from the user (optional)'), + }) +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ea49fb1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2340 @@ +{ + "name": "@bitsquare/ansiblings", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bitsquare/ansiblings", + "workspaces": [ + "packages/**", + "projects/**" + ], + "dependencies": { + "commander": "^13.1.0" + }, + "devDependencies": { + "@logtape/logtape": "*", + "@types/jest": "*", + "@types/node": "*", + "ts-node": "*", + "typescript": "*" + }, + "engines": { + "node": ">=21" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bitstack/keyman": { + "resolved": "packages/keyman", + "link": true + }, + "node_modules/@bitstack/nopy": { + "resolved": "packages/nopy", + "link": true + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@logtape/logtape": { + "version": "0.8.0", + "funding": [ + "https://github.com/sponsors/dahlia" + ], + "license": "MIT" + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, + "node_modules/@types/inquirer": { + "version": "8.2.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, + "node_modules/@types/inquirer/node_modules/rxjs": { + "version": "7.8.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.5", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "22.10.7", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/through": { + "version": "0.0.33", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/uniqid": { + "version": "5.3.4", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "license": "MIT" + }, + "node_modules/check-error": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/commander": { + "version": "13.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dotenv": { + "version": "10.0.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=10" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "9.5.2", + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/figures": { + "version": "6.1.0", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-unicode-supported": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fuzzy": { + "version": "0.1.3", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "9.0.1", + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/human-signals": { + "version": "8.0.0", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.4", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer-checkbox-plus-prompt": { + "version": "1.4.2", + "license": "MIT", + "dependencies": { + "chalk": "4.1.2", + "cli-cursor": "^3.1.0", + "figures": "^3.0.0", + "lodash": "^4.17.5", + "rxjs": "^6.6.7" + }, + "peerDependencies": { + "inquirer": "< 9.x" + } + }, + "node_modules/inquirer-checkbox-plus-prompt/node_modules/lodash": { + "version": "4.17.23", + "license": "MIT" + }, + "node_modules/inquirer-checkbox-plus-prompt/node_modules/rxjs": { + "version": "6.6.7", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "npm": ">=2.0.0" + } + }, + "node_modules/inquirer-checkbox-plus-prompt/node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-stream": { + "version": "4.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "license": "ISC" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mlly/node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "license": "ISC" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-ms": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-ms": { + "version": "9.2.0", + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/rollup": { + "version": "4.59.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-async": { + "version": "2.4.1", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.0.33", + "license": "MIT", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/type-detect": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-dotenv": { + "version": "10.0.2", + "license": "ISC", + "dependencies": { + "dotenv": "^10.0.0", + "lodash": "^4.17.20" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/acorn-walk": { + "version": "8.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/vitest/node_modules/execa": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/vitest/node_modules/get-stream": { + "version": "8.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/human-signals": { + "version": "5.0.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/vitest/node_modules/is-stream": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/mimic-fn": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/npm-run-path": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/onetime": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vitest/node_modules/strip-final-newline": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.24.1", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zx": { + "version": "8.3.0", + "license": "Apache-2.0", + "bin": { + "zx": "build/cli.js" + }, + "engines": { + "node": ">= 12.17.0" + }, + "optionalDependencies": { + "@types/fs-extra": ">=11", + "@types/node": ">=20" + } + }, + "packages/keyman": { + "name": "@bitstack/keyman", + "version": "1.0.0", + "dependencies": { + "execa": "9.5.2", + "inquirer": "8.2.4", + "ts-node": ">=10.9.1", + "typed-dotenv": "10.0.2", + "typescript": ">=5.6.3", + "zod": "^3.24.1", + "zx": "^8.3.0" + }, + "bin": { + "keyman": "dist/keyman.cli.js" + }, + "engines": { + "node": ">=21.0.0" + } + }, + "packages/nopy": { + "name": "@bitstack/nopy", + "version": "1.0.0", + "dependencies": { + "@logtape/logtape": "^0.8.0", + "commander": "^13.1.0", + "enquirer": "^2.4.1", + "execa": "9.5.2", + "fuzzy": "^0.1.3", + "inquirer": "8.2.4", + "inquirer-checkbox-plus-prompt": "^1.0.1", + "ts-node": ">=10.9.1", + "typescript": ">=5.6.3", + "yaml": "^2.8.2", + "zod": "^3.24.1", + "zx": "^8.3.0" + }, + "bin": { + "nopy": "dist/nopy.cli.js" + }, + "devDependencies": { + "@types/inquirer": "^8.2.10", + "@types/node": "^20.0.0", + "@types/uniqid": "^5.3.4", + "vitest": "^1.6.0" + } + }, + "packages/nopy/node_modules/@types/node": { + "version": "20.19.34", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.34.tgz", + "integrity": "sha512-by3/Z0Qp+L9cAySEsSNNwZ6WWw8ywgGLPQGgbQDhNRSitqYgkgp4pErd23ZSCavbtUA2CN4jQtoB3T8nk4j3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "packages/nopy/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9fdbe4c --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "@bitsquare/ansiblings", + "private": true, + "engines": { + "node": ">=21" + }, + "scripts": { + "build": "pnpm -r run build", + "build:clean": "pnpm clean && pnpm build", + "build:tsgo": "tsgo --build", + "clean": "pnpm -r run clean", + "test": "pnpm -r run test", + "typecheck": "tsgo --noEmit", + "typecheck:legacy": "tsc --noEmit", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write ." + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@logtape/logtape": "0.8.0", + "@types/jest": "29.5.5", + "@types/node": ">=21", + "@typescript/native-preview": "7.0.0-dev.20260303.1", + "ts-node": "10.9.2", + "typescript": "5.7.3" + }, + "dependencies": { + "commander": "^13.1.0" + }, + "pnpm": { + "overrides": { + "@types/jest": "29.5.5", + "@types/node": ">=21", + "@logtape/logtape": "0.8.0", + "ts-node": "10.9.2", + "typescript": "5.7.3", + "commander": "^13.1.0" + } + } +} diff --git a/packages/keyman/README.md b/packages/keyman/README.md new file mode 100644 index 0000000..a80fb57 --- /dev/null +++ b/packages/keyman/README.md @@ -0,0 +1,152 @@ +# Keyman - SSH Key Management with Age Encryption + +Keyman is a simple command line tool built around the `age` encryption tool. It allows you to manage SSH keys in public GitHub repositories securely by encrypting the private keys. + +## Features + +- 🔐 Encrypt SSH private keys with age encryption +- 📁 Organized vault structure: `vault/keys/` for encrypted keys, `vault/tmp/` for decrypted keys +- ⚙️ Configurable via `.keymanrc.json` with sensible defaults +- 🔍 Interactive CLI for encrypting, decrypting, and listing keys +- 🔄 Support for key rotation + +## Quick Start + +### 1. Generate Age Encryption Key + +```bash +# Create vault structure +mkdir -p vault/keys vault/tmp + +# Generate age encryption key (keep this secret!) +age-keygen -o vault/age.key + +# Add to .gitignore +echo "vault/age.key" >> .gitignore +echo "vault/tmp/" >> .gitignore +``` + +### 2. Generate SSH Keys + +```bash +# Generate SSH key pair +ssh-keygen -t ed25519 -f vault/tmp/id_deploy -N "" -C "deploy@myapp.dev" +``` + +### 3. Run Keyman + +```bash +# Run keyman interactively +VAULT_ROOT=./vault keyman + +# Or if you have .keymanrc.json configured, just run: +keyman +``` + +## Configuration + +Keyman uses sensible defaults but can be customized via `.keymanrc.json`: + +```json +{ + "vaultRoot": "./vault", + "keysDir": "keys", + "tmpDir": "tmp", + "ageKeyFile": "age.key" +} +``` + +### Configuration Priority + +1. **VAULT_ROOT** environment variable (highest priority) +2. **.keymanrc.json** file (searched from current directory upward) +3. **Default values** (lowest priority) + +### Default Values + +- `vaultRoot`: `"vault"` +- `keysDir`: `"keys"` +- `tmpDir`: `"tmp"` +- `ageKeyFile`: `"age.key"` + +## Vault Structure + +``` +project/ +├── vault/ +│ ├── age.key # Master encryption key (NEVER commit!) +│ ├── keys/ # Encrypted keys (safe to commit) +│ │ └── deploy/ # Each key has its own folder +│ │ ├── id_deploy.pub # Public key +│ │ └── id_deploy.age # Encrypted private key +│ └── tmp/ # Decrypted keys (NEVER commit!) +│ ├── id_deploy # Decrypted private key +│ └── id_deploy.pub # Public key +└── .keymanrc.json # Configuration (optional) +``` + +## Operations + +Keyman provides an interactive menu-driven interface with the following operations: + +- **📋 List keys** - Compact view showing all keys with checkbox indicators for their locations +- **🔒 Encrypt keys** - Encrypt SSH keys from `vault/tmp/` and store in `vault/keys/` +- **🔓 Decrypt keys** - Decrypt keys from `vault/keys/` to `vault/tmp/` or `~/.ssh/` +- **❌ Quit** - Exit the program + +After completing any operation, keyman automatically returns to the main menu, allowing you to perform multiple operations in a single session without restarting the tool. + +### List Keys Output + +The list command shows a compact, unified view of all SSH keys with their locations: + +``` +🔑 SSH Keys: + + Key Name [Vault] [Tmp] [.ssh] + ────────────────────────────────────────────────────────── + ✅ id_deploy (.pub) [✓] [ ] [✓] + 🔓 id_github (.pub) [✓] [✓] [ ] + 🔒 id_backup (.pub) [✓] [ ] [ ] + ⚠️ id_local (.pub) [ ] [ ] [✓] + + Legend: + ✅ = Managed (encrypted in vault + active in .ssh) + 🔓 = Decrypted (in vault + decrypted to tmp) + 🔒 = Encrypted only (in vault, not decrypted) + ⚠️ = Unmanaged (in .ssh or tmp, not encrypted in vault) +``` + +**Features:** +- Public keys are indicated with `(.pub)` suffix instead of separate entries +- Status emoji shows management state at a glance +- Checkboxes `[✓]` show presence in three locations: + - **[Vault]** - Encrypted in vault/keys/ + - **[Tmp]** - Decrypted in vault/tmp/ + - **[.ssh]** - Active in ~/.ssh/ +- Alphabetically sorted for easy scanning +- New **🔓** status for keys decrypted to tmp but not yet in .ssh + +## Example Usage + +```bash +# Using environment variable +VAULT_ROOT=../../vault keyman + +# Using default configuration +keyman + +# Keyman will show: +# 📁 Vault Root: /path/to/vault +# 🔑 Keys Directory: /path/to/vault/keys +# 📂 Temp Directory: /path/to/vault/tmp +# 🔐 Age Key: /path/to/vault/age.key +``` + +## Best Practices + +1. **Never commit** `vault/age.key` or `vault/tmp/` to version control +2. **Always backup** your `age.key` securely (password manager, encrypted USB) +3. **Commit** `vault/keys/` - encrypted keys are safe to share +4. **Use environment variables** for CI/CD: `VAULT_ROOT=/path/to/vault keyman` +5. **Keep .keymanrc.json** in your project root for team consistency diff --git a/packages/keyman/dist/index.d.ts b/packages/keyman/dist/index.d.ts new file mode 100644 index 0000000..29f29e8 --- /dev/null +++ b/packages/keyman/dist/index.d.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export * from './keyman.main.js'; +export { loadConfig, resolveConfigPaths } from './keyman.config.js'; diff --git a/packages/keyman/dist/index.js b/packages/keyman/dist/index.js new file mode 100644 index 0000000..29f29e8 --- /dev/null +++ b/packages/keyman/dist/index.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export * from './keyman.main.js'; +export { loadConfig, resolveConfigPaths } from './keyman.config.js'; diff --git a/packages/keyman/dist/keyman.cli.d.ts b/packages/keyman/dist/keyman.cli.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/packages/keyman/dist/keyman.cli.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/packages/keyman/dist/keyman.cli.js b/packages/keyman/dist/keyman.cli.js new file mode 100755 index 0000000..40b5966 --- /dev/null +++ b/packages/keyman/dist/keyman.cli.js @@ -0,0 +1,11 @@ +#!/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(); diff --git a/packages/keyman/dist/keyman.config.d.ts b/packages/keyman/dist/keyman.config.d.ts new file mode 100644 index 0000000..9ff8a71 --- /dev/null +++ b/packages/keyman/dist/keyman.config.d.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +/** + * Configuration schema for keyman + */ +declare const KeymanConfigSchema: z.ZodObject<{ + vaultRoot: z.ZodDefault; + keysDir: z.ZodDefault; + tmpDir: z.ZodDefault; + ageKeyFile: z.ZodDefault; +}, "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; +/** + * 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 { + /** 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 {}; diff --git a/packages/keyman/dist/keyman.config.js b/packages/keyman/dist/keyman.config.js new file mode 100644 index 0000000..ca75a8d --- /dev/null +++ b/packages/keyman/dist/keyman.config.js @@ -0,0 +1,212 @@ +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()); +} diff --git a/packages/keyman/dist/keyman.copy.d.ts b/packages/keyman/dist/keyman.copy.d.ts new file mode 100644 index 0000000..78c481b --- /dev/null +++ b/packages/keyman/dist/keyman.copy.d.ts @@ -0,0 +1 @@ +export declare function copyKey(sshDir: string, tmpDir: string): Promise; diff --git a/packages/keyman/dist/keyman.copy.js b/packages/keyman/dist/keyman.copy.js new file mode 100644 index 0000000..666557a --- /dev/null +++ b/packages/keyman/dist/keyman.copy.js @@ -0,0 +1,50 @@ +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}`); + } +} diff --git a/packages/keyman/dist/keyman.decrypt.d.ts b/packages/keyman/dist/keyman.decrypt.d.ts new file mode 100644 index 0000000..93231fa --- /dev/null +++ b/packages/keyman/dist/keyman.decrypt.d.ts @@ -0,0 +1 @@ +export declare function decryptKeys(sshDir: string, vaultDir: string, ageKey: string): Promise; diff --git a/packages/keyman/dist/keyman.decrypt.js b/packages/keyman/dist/keyman.decrypt.js new file mode 100644 index 0000000..069f58c --- /dev/null +++ b/packages/keyman/dist/keyman.decrypt.js @@ -0,0 +1,45 @@ +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}`); + } +} diff --git a/packages/keyman/dist/keyman.encrypt.d.ts b/packages/keyman/dist/keyman.encrypt.d.ts new file mode 100644 index 0000000..8473cb7 --- /dev/null +++ b/packages/keyman/dist/keyman.encrypt.d.ts @@ -0,0 +1 @@ +export declare function encryptKeys(sshDir: string, vaultDir: string, tmpDir: string, pubkey: string): Promise; diff --git a/packages/keyman/dist/keyman.encrypt.js b/packages/keyman/dist/keyman.encrypt.js new file mode 100644 index 0000000..a98b02b --- /dev/null +++ b/packages/keyman/dist/keyman.encrypt.js @@ -0,0 +1,37 @@ +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}`); + } +} diff --git a/packages/keyman/dist/keyman.generate.d.ts b/packages/keyman/dist/keyman.generate.d.ts new file mode 100644 index 0000000..5e5f541 --- /dev/null +++ b/packages/keyman/dist/keyman.generate.d.ts @@ -0,0 +1 @@ +export declare function generateKey(tmpDir: string, keysDir: string, pubkey: string): Promise; diff --git a/packages/keyman/dist/keyman.generate.js b/packages/keyman/dist/keyman.generate.js new file mode 100644 index 0000000..49c0a70 --- /dev/null +++ b/packages/keyman/dist/keyman.generate.js @@ -0,0 +1,65 @@ +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}`); + } +} diff --git a/packages/keyman/dist/keyman.list.d.ts b/packages/keyman/dist/keyman.list.d.ts new file mode 100644 index 0000000..0eb95b5 --- /dev/null +++ b/packages/keyman/dist/keyman.list.d.ts @@ -0,0 +1 @@ +export declare function listKeys(sshDir: string, vaultDir: string, tmpDir: string): Promise; diff --git a/packages/keyman/dist/keyman.list.js b/packages/keyman/dist/keyman.list.js new file mode 100644 index 0000000..22b59a2 --- /dev/null +++ b/packages/keyman/dist/keyman.list.js @@ -0,0 +1,115 @@ +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'); +} diff --git a/packages/keyman/dist/keyman.main.d.ts b/packages/keyman/dist/keyman.main.d.ts new file mode 100644 index 0000000..fbbc674 --- /dev/null +++ b/packages/keyman/dist/keyman.main.d.ts @@ -0,0 +1 @@ +export declare function keyman(): Promise; diff --git a/packages/keyman/dist/keyman.main.js b/packages/keyman/dist/keyman.main.js new file mode 100644 index 0000000..ae28df5 --- /dev/null +++ b/packages/keyman/dist/keyman.main.js @@ -0,0 +1,79 @@ +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; + } + } +} diff --git a/packages/keyman/dist/keyman.utils.d.ts b/packages/keyman/dist/keyman.utils.d.ts new file mode 100644 index 0000000..f7da94a --- /dev/null +++ b/packages/keyman/dist/keyman.utils.d.ts @@ -0,0 +1,6 @@ +/** + * 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; diff --git a/packages/keyman/dist/keyman.utils.js b/packages/keyman/dist/keyman.utils.js new file mode 100644 index 0000000..cfc8353 --- /dev/null +++ b/packages/keyman/dist/keyman.utils.js @@ -0,0 +1,21 @@ +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; + } +} diff --git a/packages/keyman/package.json b/packages/keyman/package.json new file mode 100644 index 0000000..71c12ca --- /dev/null +++ b/packages/keyman/package.json @@ -0,0 +1,29 @@ +{ + "name": "@bitstack/keyman", + "description": "A system to simplify ssh key management", + "type": "module", + "version": "1.0.0", + "private": true, + "author": "bitsquare", + "bin": "dist/keyman.cli.js", + "scripts": { + "clean": "rm -rf dist", + "build": "tsgo && chmod +x dist/keyman.cli.js && npm link", + "build:legacy": "tsc && chmod +x dist/keyman.cli.js && npm link", + "prepublishOnly": "npm run build", + "keyman": "node --loader ts-node/esm src/keyman.bin.ts" + }, + "engines": { + "node": ">=21.0.0" + }, + "files": ["dist/"], + "dependencies": { + "execa": "9.5.2", + "inquirer": "8.2.4", + "ts-node": ">=10.9.1", + "typed-dotenv": "10.0.2", + "typescript": ">=5.6.3", + "zod": "^3.24.1", + "zx": "^8.3.0" + } +} diff --git a/packages/keyman/src/index.ts b/packages/keyman/src/index.ts new file mode 100644 index 0000000..29f29e8 --- /dev/null +++ b/packages/keyman/src/index.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env node +export * from './keyman.main.js'; +export { loadConfig, resolveConfigPaths } from './keyman.config.js'; diff --git a/packages/keyman/src/keyman.cli.ts b/packages/keyman/src/keyman.cli.ts new file mode 100644 index 0000000..81a37e3 --- /dev/null +++ b/packages/keyman/src/keyman.cli.ts @@ -0,0 +1,15 @@ +#!/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(); diff --git a/packages/keyman/src/keyman.config.ts b/packages/keyman/src/keyman.config.ts new file mode 100644 index 0000000..01645a8 --- /dev/null +++ b/packages/keyman/src/keyman.config.ts @@ -0,0 +1,267 @@ +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'), +}); + +export type KeymanConfig = z.infer; + +/** + * 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 { + /** Customize merge behavior for specific properties */ + resolution?: KeymanResolutionConfig; +} + +/** + * Default configuration values + */ +const DEFAULT_CONFIG: KeymanConfig = { + 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: (keyof KeymanConfig)[] = ['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: KeymanConfigFile, + configDir: string +): KeymanConfigFile { + 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: string): string[] { + const configPaths: string[] = []; + 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: unknown, + childValue: unknown, + strategy: ResolutionStrategy +): unknown { + // 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: Record = { ...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: KeymanConfig, childFile: KeymanConfigFile): KeymanConfig { + const resolution = childFile.resolution || {}; + const result: Record = { ...parent }; + + for (const [key, value] of Object.entries(childFile)) { + if (key === 'resolution') continue; // Skip resolution property itself + + const strategy = resolution[key as keyof KeymanConfig] || 'merge'; + if (key in result) { + result[key] = mergeValue(result[key], value, strategy); + } else { + result[key] = value; + } + } + + return result as unknown as KeymanConfig; +} + +/** + * 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(): KeymanConfig { + 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: KeymanConfig = { ...DEFAULT_CONFIG }; + + for (const configPath of configPaths) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + const rawConfig = JSON.parse(content) as KeymanConfigFile; + // 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: KeymanConfig) { + // 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(): string[] { + return findConfigFiles(process.cwd()); +} diff --git a/packages/keyman/src/keyman.copy.ts b/packages/keyman/src/keyman.copy.ts new file mode 100644 index 0000000..290e870 --- /dev/null +++ b/packages/keyman/src/keyman.copy.ts @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; + +export async function copyKey(sshDir: string, tmpDir: string) { + const getKeys = (dir: string) => { + 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<{ selectedKey: string }>([ + { + 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}`); + } +} diff --git a/packages/keyman/src/keyman.decrypt.ts b/packages/keyman/src/keyman.decrypt.ts new file mode 100644 index 0000000..e278910 --- /dev/null +++ b/packages/keyman/src/keyman.decrypt.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; + +export async function decryptKeys(sshDir: string, vaultDir: string, ageKey: string) { + 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}`); + } +} diff --git a/packages/keyman/src/keyman.encrypt.ts b/packages/keyman/src/keyman.encrypt.ts new file mode 100644 index 0000000..62aebd8 --- /dev/null +++ b/packages/keyman/src/keyman.encrypt.ts @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; + +export async function encryptKeys( + sshDir: string, + vaultDir: string, + tmpDir: string, + pubkey: string +) { + 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<{ selectedKeys: string[] }>([ + { + 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}`); + } +} diff --git a/packages/keyman/src/keyman.generate.ts b/packages/keyman/src/keyman.generate.ts new file mode 100644 index 0000000..d938f2c --- /dev/null +++ b/packages/keyman/src/keyman.generate.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; + +export async function generateKey(tmpDir: string, keysDir: string, pubkey: string) { + const { algorithm } = await inquirer.prompt<{ algorithm: string }>([ + { + type: 'list', + name: 'algorithm', + message: 'Select algorithm:', + choices: ['ed25519', 'rsa'], + default: 'ed25519', + }, + ]); + + const { keyName } = await inquirer.prompt<{ keyName: string }>([ + { + type: 'input', + name: 'keyName', + message: 'Enter key name:', + validate: (input) => (input.trim() !== '' ? true : 'Key name cannot be empty'), + }, + ]); + + const { password } = await inquirer.prompt<{ password: string }>([ + { + type: 'password', + name: 'password', + message: 'Enter passphrase (leave empty for no passphrase):', + mask: '*', + }, + ]); + + const { identity } = await inquirer.prompt<{ identity: string }>([ + { + type: 'input', + name: 'identity', + message: 'Enter key identity (comment):', + }, + ]); + + const fileName = keyName.startsWith('id_') ? keyName : `id_${keyName}`; + const 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}`); + } +} diff --git a/packages/keyman/src/keyman.list.ts b/packages/keyman/src/keyman.list.ts new file mode 100644 index 0000000..c430c3c --- /dev/null +++ b/packages/keyman/src/keyman.list.ts @@ -0,0 +1,139 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +interface KeyInfo { + name: string; + inSsh: boolean; + hasSshPub: boolean; + inVault: boolean; + inTmp: boolean; + hasTmpPub: boolean; +} + +export async function listKeys(sshDir: string, vaultDir: string, tmpDir: string) { + 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'); +} diff --git a/packages/keyman/src/keyman.main.ts b/packages/keyman/src/keyman.main.ts new file mode 100644 index 0000000..f190b36 --- /dev/null +++ b/packages/keyman/src/keyman.main.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; +import { loadConfig, resolveConfigPaths } from './keyman.config.js'; +import { copyKey } from './keyman.copy.js'; +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<{ user: string }>([ + { + 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<{ category: string }>([ + { + 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; + } + } +} diff --git a/packages/keyman/src/keyman.utils.ts b/packages/keyman/src/keyman.utils.ts new file mode 100644 index 0000000..880ded8 --- /dev/null +++ b/packages/keyman/src/keyman.utils.ts @@ -0,0 +1,23 @@ +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: string): string | null { + 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; + } +} diff --git a/packages/keyman/tsconfig.json b/packages/keyman/tsconfig.json new file mode 100644 index 0000000..c0f22cd --- /dev/null +++ b/packages/keyman/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2020"], + "composite": true, + "module": "NodeNext", + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["coverage", "node_modules", "dist"], + "references": [] +} diff --git a/packages/nopy/.nopyrc.json b/packages/nopy/.nopyrc.json new file mode 100644 index 0000000..cb7b9a7 --- /dev/null +++ b/packages/nopy/.nopyrc.json @@ -0,0 +1,11 @@ +{ + "hosts": ["@docker/nopy-test-container", "tecpi.local"], + "cubeDirs": ["./cubes"], + "env": { + "KEY_DIR": "../../vault/tmp" + }, + "log": { + "verbosity": "trace", + "debug": true + } +} diff --git a/packages/nopy/NOPY.REFACTORING.md b/packages/nopy/NOPY.REFACTORING.md new file mode 100644 index 0000000..2b30e67 --- /dev/null +++ b/packages/nopy/NOPY.REFACTORING.md @@ -0,0 +1,41 @@ +# Nopy Refactoring Plan + +This document tracks the major refactoring of the `nopy` package. + +## Refactoring Items + +### 1. Remove parallel execution +- **Status**: ✅ Completed +- **Goal**: Remove all logic supporting parallel execution of cubes to simplify the execution flow and improve reliability. +- **Context**: + - Parallelism removed from `NopyConfig`, `NopyOptions`, and `executeDeployCalls`. + - `buildExecutionStages` deleted. + - CLI flags `--parallel` and `--concurrency` removed. +- **Proposed Solution**: (Done) + +### 2. Rework cube building process & Dependency Resolution +- **Status**: ✅ Completed +- **Goal**: Allow dependencies to be defined as a function of the collected variables. +- **New Signature**: `dependencies?: (variables: CubeVariables) => DependencySpec[]` +- **Architectural Change**: Implement a clean, step-based resolution mechanism using a `BuildContext`. +- **Context**: + - Introduced `BuildContext` in `cubes/dependencies.ts` which handles recursive resolution, variable collection, and hook execution. + - Resolution is now dynamic: variables are collected for a cube before its dependencies are resolved. +- **Proposed Solution**: (Done) + +### 3. Remove `env` property from cube Manifest +- **Status**: ✅ Completed +- **Goal**: Remove the `env` property from the cube manifest. +- **Context**: + - `env` removed from `Manifest` and `Env` types. + - Responsibility for defaults shifted entirely to Zod schema defaults and `getDefaults()`. +- **Proposed Solution**: (Done) + +### 4. Redesign Manifest and Cube types +- **Status**: ✅ Completed +- **Goal**: Transition from `Env -> Manifest -> Cube` inheritance to a cleaner `Manifest` (specification) and `Cube` (runtime) separation. +- **Context**: + - `Manifest` is now a clean interface with a factory namespace. + - `Cube` is a class encapsulating a `Manifest` and runtime info (`dir`, `deployScript`). +- **Proposed Solution**: (Done) + diff --git a/packages/nopy/README.DOCKER.md b/packages/nopy/README.DOCKER.md new file mode 100644 index 0000000..1115806 --- /dev/null +++ b/packages/nopy/README.DOCKER.md @@ -0,0 +1,82 @@ +# Testing Nopy with Docker + +This guide explains how to set up a local Docker container to test `nopy` deployments using the provided `Ubuntu-24LTS.Dockerfile` and `example.nopysession.json`. + +## Prerequisites + +- Docker installed and running on your machine. +- `nopy` installed and linked (see [README.md](./README.md)). + +## 1. Setup SSH Key (Important) + +The provided `Ubuntu-24LTS.Dockerfile` contains a hardcoded public SSH key. Before building, you **must** replace it with your own public key to allow SSH access (if needed) or ensure `pyinfra` can connect if it uses SSH transport. + +1. Open `Ubuntu-24LTS.Dockerfile`. +2. Locate the line starting with `echo "ssh-ed25519 ...`. +3. Replace the key string with the content of your own public key (usually `~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`). + +```dockerfile +# Example replacement +RUN mkdir -p /home/testuser/.ssh && \ + echo "YOUR_PUBLIC_KEY_HERE" >> /home/testuser/.ssh/authorized_keys && \ + ... +``` + +## 2. Build the Docker Image + +Run the following command from the `packages/nopy` directory: + +```bash +docker build -f Ubuntu-24LTS.Dockerfile -t nopy-test-ubuntu . +``` + +## 3. Run the Container + +Start the container in the background. We explicitly name it `nopy-test-container` because the `example.nopysession.json` is configured to target this specific container name. + +```bash +docker run -d \ + --name nopy-test-container \ + --privileged \ + -p 2222:22 \ + nopy-test-ubuntu +``` + +- `--name nopy-test-container`: Matches the host defined in `example.nopysession.json`. +- `--privileged`: Required for some system-level operations (like `criu` or service management) if tested. +- `-p 2222:22`: Maps the container's SSH port to local port 2222 (optional, allows manual SSH connection). + +## 4. Deploy using Nopy + +Now you can run the example session. Nopy uses `pyinfra`'s `@docker` connector to communicate directly with the container, so SSH keys are not strictly required for the *deployment* itself, but the session is configured to simulate a realistic environment. + +```bash +nopy install -l example.nopysession.json +``` + +If successful, `nopy` will execute the `apt:essentials` cube against the container. + +## 5. Manual Verification + +You can connect to the container manually to verify changes: + +**Via Docker Exec:** + +```bash +docker exec -it nopy-test-container bash +``` + +**Via SSH (if configured):** + +```bash +ssh -p 2222 testuser@localhost +# Password: password +``` + +## 6. Cleanup + +To stop and remove the container: + +```bash +docker rm -f nopy-test-container +``` diff --git a/packages/nopy/README.VAGRANT.md b/packages/nopy/README.VAGRANT.md new file mode 100644 index 0000000..f402663 --- /dev/null +++ b/packages/nopy/README.VAGRANT.md @@ -0,0 +1,21 @@ +# Vagrant + +`vagrant ssh-config` to find the SSH port of the machine +`vagrant status --machine-readable` will be executed by pyinfra to get information about available VMs + +```ruby + +Vagrant.configure("2") do |config| + # DO NOT USE special characters in vm name + config.vm.define "nopytestvm" + config.vm.provider "vmware_desktop" do |vmware| + vmware.gui = false + vmware.allowlist_verified = true + end + config.vm.box = "bento/ubuntu-24.04" # Use Ubuntu 24.04 box + config.ssh.insert_key = false + config.vm.box_check_update = false + config.vm.hostname = "nopytestvm" + end + +``` diff --git a/packages/nopy/README.md b/packages/nopy/README.md new file mode 100644 index 0000000..e038f82 --- /dev/null +++ b/packages/nopy/README.md @@ -0,0 +1,380 @@ +# Nopy + +A CLI tool that simplifies **pyinfra** script management and execution, providing an interactive workflow for deploying infrastructure configurations ("cubes") to remote hosts. + +## Overview + +Nopy wraps pyinfra with structure, validation, and an interactive experience for managing complex infrastructure deployments. It organizes deployments into self-contained "cubes" with dependency management, schema validation, and lifecycle hooks. + +## Core Concepts + +### Cubes + +Self-contained deployment units consisting of: + +- **Python deployment script**: `.deploy.py` +- **JavaScript manifest**: `.manifest.mjs` defining schema, dependencies, defaults, and hooks +- **Configuration variables**: Validated with Zod schemas + +#### Cube Manifest + +```javascript +import { z } from 'zod' +import { cubes } from '@bitstack/nopy' + +export default cubes.Manifest({ + id: 'apt:install', + name: 'Install packages with apt', + dependencies: () => [], + schema: z.object({ + UPDATE: z.boolean().describe('Update package cache').default(false), + PACKAGES: z.string().describe('Space-separated list of packages').default('vim htop'), + }) +}) +``` + +#### Variable Defaults + +Variable defaults are defined directly in the Zod schema using `.default()`. This ensures that every cube has a predictable starting state and provides type-safe default values. + +**Priority order (lowest to highest):** + +1. Zod schema `.default()` values +2. Global `env` from `.nopyrc.json` +3. Accumulated variables from dependencies +4. User prompts / session replay + +This allows cubes to ship with reasonable defaults while still allowing users to override them globally via `.nopyrc.json` or interactively during deployment. + +### Configuration + +Uses `.nopyrc.json` files (project-level or home directory) containing: + +```json +{ + "hosts": ["host1.example.com", "host2.example.com"], + "cubeDirs": ["./cubes", "../shared-cubes"], + "env": { + "SHARED_VAR": "value" + }, + "log": { + "verbosity": "info", + "debug": false + } +} +``` + +#### Logging Configuration + +Control pyinfra output verbosity and debug information using the `log` configuration object: + +**`log.verbosity`** - Controls the level of information printed during execution: + +| Verbosity | PyInfra Flag | Description | Use Case | +|-----------|--------------|-------------|----------| +| `"silent"` | (none) | Minimal output (default) | Production deployments, clean output | +| `"info"` | `-v` | Print meta information | See what operations are running | +| `"verbose"` | `-vv` | Include input data | Debug parameters and configuration | +| `"trace"` | `-vvv` | Full command output | See all command outputs and details | + +**`log.debug`** - Enables pyinfra's internal debug logging: + +| Value | PyInfra Flag | Description | Use Case | +|-------|--------------|-------------|----------| +| `false` | (none) | No debug logs (default) | Normal operation | +| `true` | `--debug` | Enable pyinfra debug logs | Deep debugging of pyinfra internals | + +**Examples:** + +Basic troubleshooting: + +```json +{ + "log": { + "verbosity": "info" + } +} +``` + +Debug command failures: + +```json +{ + "log": { + "verbosity": "trace" + } +} +``` + +Deep debugging with pyinfra internals: + +```json +{ + "log": { + "verbosity": "trace", + "debug": true + } +} +``` + +**Recommendation:** Start with `"info"` for typical troubleshooting, use `"trace"` when investigating command failures, and enable `debug: true` only when debugging pyinfra itself. + +### Session Recording and Replay + +Nopy supports recording deployment sessions to JSON files for later replay. This is useful for: + +- Repeatable deployments +- CI/CD pipelines +- Documentation and auditing +- Sharing configurations across teams + +#### Session File Format + +Sessions are stored in `.nopysession.json` files with the following structure: + +```json +{ + "version": "1.0.0", + "name": "My Deployment Session", + "timestamp": "2025-10-13T10:30:00Z", + "cubes": [ + { + "key": "apt:essentials", + "variables": { + "UPDATE": true + } + }, + { + "key": "apt-more", + "variables": { + "SOME_VAR": "value" + } + } + ], + "hosts": [ + "@docker/nopy-test-container" + ], + "env": { + "KEY_DIR": "../../vault/tmp" + }, + "auth": { + "method": "ssh-key", + "username": "root" + } +} +``` + +**Structure Details:** + +- **`cubes`**: Array of cubes with only cube-specific variables (not global env vars) +- **`env`**: Global environment variables shared across cubes (like in `.nopyrc.json`) +- **`hosts`**: Array of target hosts +- **`auth`**: Authentication configuration (passwords are never stored) + +**Security Note**: Passwords are never stored in session files. If a session uses password authentication, you'll be prompted for the password during replay. + +#### Recording a Session + +```bash +# Run deployment interactively and save the session +nopy install --save-session my-deployment.nopysession.json + +# With defaults (no prompts for variables) +nopy install -D --save-session automated-deployment.nopysession.json +``` + +#### Replaying a Session + +```bash +# Load and execute a saved session +nopy install --load-session my-deployment.nopysession.json + +# Session replay uses the exact cubes, variables, and hosts from the file +# Only password authentication will prompt for credentials +``` + +### Cube Discovery + +Nopy searches for cubes in: + +1. Directories specified in `.nopyrc.json` `cubeDirs` +2. Directories containing a `.npcubes` marker file (searching upwards from current directory) + +## Command Line Usage + +### Installation + +This package is part of a yarn workspace monorepo. Install from the repository root: + +```bash +# From repository root (/ansiblings) +yarn install +yarn workspace @bitstack/nopy build +``` + +To use the `nopy` command globally, you can: + +1. **Use yarn workspace command**: + + ```bash + yarn workspace @bitstack/nopy nopy + ``` + +2. **Link the package globally**: + + ```bash + cd packages/nopy + npm link + # Now you can use 'nopy' from anywhere + nopy install + ``` + +3. **Use via npm scripts** (from packages/nopy directory): + + ```bash + yarn nopy + ``` + +### Basic Commands + +**Install cubes (default command)**: + +```bash +nopy install +# or simply +nopy +``` + +**Install with defaults (no prompts for customization)**: + +```bash +nopy install --use-defaults +# or +nopy install -D +``` + +**Use SSH key authentication**: + +```bash +nopy install --auth-method-key +# or +nopy install -K +``` + +**Repeat last run**: + +```bash +nopy install --repeat-last-run +# or +nopy install -R +``` + +**Save session for replay**: + +```bash +nopy install --save-session my-deployment.nopysession.json +# or +nopy install -s my-deployment.nopysession.json +``` + +**Load and replay session**: + +```bash +nopy install --load-session my-deployment.nopysession.json +# or +nopy install -l my-deployment.nopysession.json +``` + +**Combined options**: + +```bash +nopy install -D -K # Use defaults + SSH key auth +nopy install -D -s session.nopysession.json # Use defaults and save session +``` + +### Advanced Options + +**Dry run (preview without executing)**: + +```bash +nopy install --dry-run +``` + +Shows the execution plan including commands, environment variables, and targets without running anything. Sensitive data is masked in output. + +**Parallel execution**: + +```bash +nopy install --parallel +``` + +Executes independent cubes in parallel using a dependency graph. Cubes are grouped into execution stages, with a default concurrency limit of 4. + +**JSON output (for CI/CD)**: + +```bash +nopy install --json +nopy history --json +``` + +Machine-readable JSON output for scripting and CI/CD integration. + +**Continue on error**: + +```bash +nopy install --continue-on-error +``` + +Continue deploying remaining cubes even if one fails. + +**View deployment history**: + +```bash +nopy history # List recent deployments +nopy install -H # Replay a specific deployment by ID +``` + +### Development + +**Run without building**: + +```bash +npm run nopy +``` + +**Debug**: + +```bash +npm run debug +``` + +## Workflow + +1. **Load cubes** - Discovers and validates cubes from configured directories +2. **Interactive prompts** - Select cubes, target host, and authentication method +3. **Dependency resolution** - Topologically sorts cubes based on dependencies +4. **Variable assignment** - Validates and collects configuration with schema validation +5. **Execute hooks** - Runs before/after hooks for orchestration +6. **Deploy** - Sequentially executes pyinfra commands + +## Features + +- **Dependency resolution** with topological sorting +- **Parallel execution** of independent cubes in stages +- **Before/after hooks** for multi-cube orchestration +- **SSH key or password authentication** +- **Default values** with optional customization via manifest `env` +- **Schema validation** using Zod +- **Recursive cube directory discovery** +- **Dry-run mode** for previewing deployments +- **JSON output** for CI/CD integration +- **Session history** with replay capability + +## Documentation + +- [Cube Hooks](docs/HOOKS.md) - Lifecycle hooks for dynamic orchestration +- [Session Format](docs/SESSION_FORMAT.md) - Internal JSON/MJS session structure + +## Resources + +- [Pyinfra Documentation](https://docs.pyinfra.com/en/3.x/arguments.html) diff --git a/packages/nopy/Ubuntu-24LTS.Dockerfile b/packages/nopy/Ubuntu-24LTS.Dockerfile new file mode 100644 index 0000000..ed68ee0 --- /dev/null +++ b/packages/nopy/Ubuntu-24LTS.Dockerfile @@ -0,0 +1,33 @@ +FROM ubuntu:24.04 + +# Install software-properties-common +RUN apt-get update && \ + apt-get install -y software-properties-common && \ + rm -rf /var/lib/apt/lists/* + +# Add the CRIU repository +RUN add-apt-repository ppa:criu/ppa -y + +# Update apt cache and install SSH server, basic utilities, and CRIU fro snapshot management +RUN apt-get update && \ + apt-get install -y openssh-server sudo curl htop criu && \ + rm -rf /var/lib/apt/lists/* + +# Create a user (replace 'testuser' with your preferred username) +RUN useradd -m -d /home/testuser -s /bin/bash testuser +RUN echo 'testuser:password' | chpasswd +RUN adduser testuser sudo + +# Add your public key (replace with your actual public key) +RUN mkdir -p /home/testuser/.ssh && \ + echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpnZ6IxwQKL1rBE4dy7w5Sd3s2tLFZUDfjH87C1QIlc bdiedrichsen@Benjamins-MBP.lan" >> /home/testuser/.ssh/authorized_keys && \ + chmod 600 /home/testuser/.ssh/authorized_keys && \ + chown testuser:testuser /home/testuser/.ssh/authorized_keys + +# Create necessary directories +RUN mkdir -p /run/sshd +# Expose SSH port +EXPOSE 22 + +# Set the default command to keep the container running +CMD ["/usr/sbin/sshd", "-D"] \ No newline at end of file diff --git a/packages/nopy/cubes/apt/all/deploy.py b/packages/nopy/cubes/apt/all/deploy.py new file mode 100644 index 0000000..94952f9 --- /dev/null +++ b/packages/nopy/cubes/apt/all/deploy.py @@ -0,0 +1,4 @@ +from pyinfra.operations import apt +from pyinfra import host + + diff --git a/packages/nopy/cubes/apt/all/manifest.mjs b/packages/nopy/cubes/apt/all/manifest.mjs new file mode 100644 index 0000000..67924ba --- /dev/null +++ b/packages/nopy/cubes/apt/all/manifest.mjs @@ -0,0 +1,7 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + name: '[apt-all] Test dependencies', + dependencies: () => ['apt/more'], +}); diff --git a/packages/nopy/cubes/apt/essentials/deploy.py b/packages/nopy/cubes/apt/essentials/deploy.py new file mode 100644 index 0000000..5ba379b --- /dev/null +++ b/packages/nopy/cubes/apt/essentials/deploy.py @@ -0,0 +1,16 @@ +from pyinfra.operations import apt +from pyinfra import host + +UPDATE = host.data.UPDATE + +apt.packages( + name='Install essentials', + packages=[ + 'fish', + 'ranger', + 'age' + ], + update=UPDATE, + _sudo=True +) + diff --git a/packages/nopy/cubes/apt/essentials/manifest.mjs b/packages/nopy/cubes/apt/essentials/manifest.mjs new file mode 100644 index 0000000..5aecbdb --- /dev/null +++ b/packages/nopy/cubes/apt/essentials/manifest.mjs @@ -0,0 +1,10 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + name: '[apt:essentials] Install essential packages', + dependencies: () => [], + schema: z.object({ + UPDATE: z.boolean().default(false), + }), +}); diff --git a/packages/nopy/cubes/apt/more/deploy.py b/packages/nopy/cubes/apt/more/deploy.py new file mode 100644 index 0000000..79cdcc1 --- /dev/null +++ b/packages/nopy/cubes/apt/more/deploy.py @@ -0,0 +1,16 @@ +from pyinfra.operations import apt +from pyinfra import host + +UPDATE = host.data.UPDATE + +apt.packages( + name='Install essentials', + packages=[ + 'build-essential', + 'pkg-config', + 'age' + ], + update=UPDATE, + _sudo=True +) + diff --git a/packages/nopy/cubes/apt/more/manifest.mjs b/packages/nopy/cubes/apt/more/manifest.mjs new file mode 100644 index 0000000..b37794a --- /dev/null +++ b/packages/nopy/cubes/apt/more/manifest.mjs @@ -0,0 +1,7 @@ +import { cubes } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default cubes.Manifest({ + name: '[apt-more] Test dependencies', + dependencies: () => [['apt/essentials']], +}); diff --git a/packages/nopy/dist/cubes/dependencies.d.ts b/packages/nopy/dist/cubes/dependencies.d.ts new file mode 100644 index 0000000..c9f0915 --- /dev/null +++ b/packages/nopy/dist/cubes/dependencies.d.ts @@ -0,0 +1,46 @@ +/** + * 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; + 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, 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; + /** + * Builds and stores a deployment call for a resolved cube + */ + private buildDeployCall; +} diff --git a/packages/nopy/dist/cubes/dependencies.js b/packages/nopy/dist/cubes/dependencies.js new file mode 100644 index 0000000..7e46643 --- /dev/null +++ b/packages/nopy/dist/cubes/dependencies.js @@ -0,0 +1,114 @@ +/** + * 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); + } +} diff --git a/packages/nopy/dist/cubes/factories.d.ts b/packages/nopy/dist/cubes/factories.d.ts new file mode 100644 index 0000000..b580049 --- /dev/null +++ b/packages/nopy/dist/cubes/factories.d.ts @@ -0,0 +1,21 @@ +/** + * 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(opts: Pick, 'name'> & Partial, 'name'>>): Manifest; +/** + * 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'; diff --git a/packages/nopy/dist/cubes/factories.js b/packages/nopy/dist/cubes/factories.js new file mode 100644 index 0000000..5f2af2d --- /dev/null +++ b/packages/nopy/dist/cubes/factories.js @@ -0,0 +1,23 @@ +/** + * 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'; diff --git a/packages/nopy/dist/cubes/index.d.ts b/packages/nopy/dist/cubes/index.d.ts new file mode 100644 index 0000000..5ef1625 --- /dev/null +++ b/packages/nopy/dist/cubes/index.d.ts @@ -0,0 +1,13 @@ +/** + * 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'; diff --git a/packages/nopy/dist/cubes/index.js b/packages/nopy/dist/cubes/index.js new file mode 100644 index 0000000..d001d15 --- /dev/null +++ b/packages/nopy/dist/cubes/index.js @@ -0,0 +1,17 @@ +/** + * 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'; diff --git a/packages/nopy/dist/cubes/loader.d.ts b/packages/nopy/dist/cubes/loader.d.ts new file mode 100644 index 0000000..6e7cb00 --- /dev/null +++ b/packages/nopy/dist/cubes/loader.d.ts @@ -0,0 +1,22 @@ +/** + * 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; +/** + * Gets information about a single cube by name. + */ +export declare function getCube(cubeName: string): Promise; diff --git a/packages/nopy/dist/cubes/loader.js b/packages/nopy/dist/cubes/loader.js new file mode 100644 index 0000000..d3f9f7f --- /dev/null +++ b/packages/nopy/dist/cubes/loader.js @@ -0,0 +1,102 @@ +/** + * 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]; +} diff --git a/packages/nopy/dist/cubes/types.d.ts b/packages/nopy/dist/cubes/types.d.ts new file mode 100644 index 0000000..6154c91 --- /dev/null +++ b/packages/nopy/dist/cubes/types.d.ts @@ -0,0 +1,74 @@ +/** + * Type definitions for Nopy cubes + * @module cubes/types + */ +import { z } from 'zod'; +/** + * Variables that can be passed to a cube + */ +export type CubeVariables = Record; +/** + * 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; +} +/** + * Hook function type for before/after cube execution + */ +export type Hook = (ctx: HookContext, variables: z.infer) => void | Promise; +/** + * User-defined specification for a cube + */ +export interface Manifest { + /** 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) => DependencySpec[]; + /** Hooks to run before cube execution */ + before?: Hook[]; + /** Hooks to run after cube execution */ + after?: Hook[]; +} +/** + * Factory function and namespace for Manifest + */ +export declare function Manifest(opts: Pick, 'name'> & Partial, 'name'>>): Manifest; +export declare namespace Manifest { + /** + * Internal create helper + */ + function create(opts: Pick, 'name'> & Partial, 'name'>>): Manifest; +} +/** + * A fully loaded cube with its filesystem location and runtime state + */ +export declare class Cube { + readonly manifest: Manifest; + readonly dir: string; + readonly deployScript: string; + constructor(manifest: Manifest, dir: string, deployScript: string); + get id(): string; + get name(): string; + /** + * Returns default values for the cube's schema + */ + getDefaults(): z.infer; +} +/** + * Result of loading cubes from the filesystem + */ +export interface LoadResult { + /** Map of cube key to Cube object */ + cubes: Record; + /** List of errors encountered during loading */ + errors: string[]; +} diff --git a/packages/nopy/dist/cubes/types.js b/packages/nopy/dist/cubes/types.js new file mode 100644 index 0000000..98376a5 --- /dev/null +++ b/packages/nopy/dist/cubes/types.js @@ -0,0 +1,57 @@ +/** + * 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 {}; + } + } +} diff --git a/packages/nopy/dist/cubes/utils.d.ts b/packages/nopy/dist/cubes/utils.d.ts new file mode 100644 index 0000000..f586957 --- /dev/null +++ b/packages/nopy/dist/cubes/utils.d.ts @@ -0,0 +1,20 @@ +/** + * 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; diff --git a/packages/nopy/dist/cubes/utils.js b/packages/nopy/dist/cubes/utils.js new file mode 100644 index 0000000..05ea29e --- /dev/null +++ b/packages/nopy/dist/cubes/utils.js @@ -0,0 +1,33 @@ +/** + * 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(''); +} diff --git a/packages/nopy/dist/index.d.ts b/packages/nopy/dist/index.d.ts new file mode 100644 index 0000000..7d3b995 --- /dev/null +++ b/packages/nopy/dist/index.d.ts @@ -0,0 +1,20 @@ +/** + * 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'; diff --git a/packages/nopy/dist/index.js b/packages/nopy/dist/index.js new file mode 100644 index 0000000..28658ef --- /dev/null +++ b/packages/nopy/dist/index.js @@ -0,0 +1,23 @@ +/** + * 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'; diff --git a/packages/nopy/dist/nopy.cli.d.ts b/packages/nopy/dist/nopy.cli.d.ts new file mode 100644 index 0000000..1b05743 --- /dev/null +++ b/packages/nopy/dist/nopy.cli.d.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +/** + * Nopy CLI - pyinfra deployment management + * @module nopy.cli + */ +export {}; diff --git a/packages/nopy/dist/nopy.cli.js b/packages/nopy/dist/nopy.cli.js new file mode 100755 index 0000000..d4cf4ee --- /dev/null +++ b/packages/nopy/dist/nopy.cli.js @@ -0,0 +1,127 @@ +#!/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 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 ' 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 ', 'Run a specific session from history by ID') + .option('-s, --save-session ', 'Save session to file for later replay') + .option('-l, --load-session ', '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(); diff --git a/packages/nopy/dist/nopy.common.d.ts b/packages/nopy/dist/nopy.common.d.ts new file mode 100644 index 0000000..f791e5f --- /dev/null +++ b/packages/nopy/dist/nopy.common.d.ts @@ -0,0 +1,20 @@ +/** + * Environment variable configuration + */ +export type TVariables = Record; +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; + /** @summary env as configured via prompts */ + prompts: Record; + /** @summary env as handed via params (on hook calls) */ + params: Record; + constructor(global?: TVariables); + assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values?: TVariables): void; + get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables; +} diff --git a/packages/nopy/dist/nopy.common.js b/packages/nopy/dist/nopy.common.js new file mode 100644 index 0000000..50c0306 --- /dev/null +++ b/packages/nopy/dist/nopy.common.js @@ -0,0 +1,32 @@ +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], + }; + } +} diff --git a/packages/nopy/dist/nopy.config.d.ts b/packages/nopy/dist/nopy.config.d.ts new file mode 100644 index 0000000..54f1373 --- /dev/null +++ b/packages/nopy/dist/nopy.config.d.ts @@ -0,0 +1,114 @@ +/** + * 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 { + /** 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, 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[]; diff --git a/packages/nopy/dist/nopy.config.js b/packages/nopy/dist/nopy.config.js new file mode 100644 index 0000000..101df9f --- /dev/null +++ b/packages/nopy/dist/nopy.config.js @@ -0,0 +1,263 @@ +/** + * 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; +} diff --git a/packages/nopy/dist/nopy.cubes.d.ts b/packages/nopy/dist/nopy.cubes.d.ts new file mode 100644 index 0000000..b1ae7a1 --- /dev/null +++ b/packages/nopy/dist/nopy.cubes.d.ts @@ -0,0 +1,22 @@ +/** + * 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'; diff --git a/packages/nopy/dist/nopy.cubes.js b/packages/nopy/dist/nopy.cubes.js new file mode 100644 index 0000000..29fed65 --- /dev/null +++ b/packages/nopy/dist/nopy.cubes.js @@ -0,0 +1,16 @@ +/** + * 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, +}; diff --git a/packages/nopy/dist/nopy.executor.d.ts b/packages/nopy/dist/nopy.executor.d.ts new file mode 100644 index 0000000..5a23c29 --- /dev/null +++ b/packages/nopy/dist/nopy.executor.d.ts @@ -0,0 +1,92 @@ +/** + * 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; + /** 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; +/** + * 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[]; +}; diff --git a/packages/nopy/dist/nopy.executor.js b/packages/nopy/dist/nopy.executor.js new file mode 100644 index 0000000..fe30f82 --- /dev/null +++ b/packages/nopy/dist/nopy.executor.js @@ -0,0 +1,138 @@ +/** + * 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, + }; +} diff --git a/packages/nopy/dist/nopy.history.d.ts b/packages/nopy/dist/nopy.history.d.ts new file mode 100644 index 0000000..0b8c643 --- /dev/null +++ b/packages/nopy/dist/nopy.history.d.ts @@ -0,0 +1,90 @@ +/** + * 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; diff --git a/packages/nopy/dist/nopy.history.js b/packages/nopy/dist/nopy.history.js new file mode 100644 index 0000000..5bc4fbe --- /dev/null +++ b/packages/nopy/dist/nopy.history.js @@ -0,0 +1,170 @@ +/** + * 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'); +} diff --git a/packages/nopy/dist/nopy.main.d.ts b/packages/nopy/dist/nopy.main.d.ts new file mode 100644 index 0000000..72eb07a --- /dev/null +++ b/packages/nopy/dist/nopy.main.d.ts @@ -0,0 +1,38 @@ +/** + * 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; diff --git a/packages/nopy/dist/nopy.main.js b/packages/nopy/dist/nopy.main.js new file mode 100644 index 0000000..c85a3bd --- /dev/null +++ b/packages/nopy/dist/nopy.main.js @@ -0,0 +1,163 @@ +/** + * 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 ? '' : ''}`); + } + } + 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, + }, + }; +} diff --git a/packages/nopy/dist/nopy.prompts.d.ts b/packages/nopy/dist/nopy.prompts.d.ts new file mode 100644 index 0000000..e775893 --- /dev/null +++ b/packages/nopy/dist/nopy.prompts.d.ts @@ -0,0 +1,21 @@ +/** + * 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): Promise<{ + selectedCubes: string[]; +}>; +export declare function AuthSelection(useAuthKey?: boolean): Promise<{ + authMethod: string; + username?: string; + password?: string; +}>; +export declare function PasswordSelection(username: string): Promise; +export declare function HostSelection(hosts: string[]): Promise; +export declare function VariableAssignment(cube: Cube, variables: Variables): Promise; diff --git a/packages/nopy/dist/nopy.prompts.js b/packages/nopy/dist/nopy.prompts.js new file mode 100644 index 0000000..76a6243 --- /dev/null +++ b/packages/nopy/dist/nopy.prompts.js @@ -0,0 +1,174 @@ +/** + * 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 + } +} diff --git a/packages/nopy/dist/nopy.session.d.ts b/packages/nopy/dist/nopy.session.d.ts new file mode 100644 index 0000000..8285d95 --- /dev/null +++ b/packages/nopy/dist/nopy.session.d.ts @@ -0,0 +1,112 @@ +/** + * 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; +/** + * 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; +/** + * 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): Record; +/** + * 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, envVariables: Record): { + env: Record; + cubeVars: Record; +}; diff --git a/packages/nopy/dist/nopy.session.js b/packages/nopy/dist/nopy.session.js new file mode 100644 index 0000000..bc1c893 --- /dev/null +++ b/packages/nopy/dist/nopy.session.js @@ -0,0 +1,166 @@ +/** + * 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 }; +} diff --git a/packages/nopy/dist/nopy.workflow.d.ts b/packages/nopy/dist/nopy.workflow.d.ts new file mode 100644 index 0000000..e38373b --- /dev/null +++ b/packages/nopy/dist/nopy.workflow.d.ts @@ -0,0 +1,49 @@ +/** + * 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, config: NopyConfig, options?: WorkflowOptions): Promise; +/** + * Runs the replay workflow from a saved session file + */ +export declare function runReplayWorkflow(sessionPath: string, cubes: Record, config: NopyConfig): Promise; +/** + * Runs replay workflow from a session object (from history) + */ +export declare function runSessionReplayWorkflow(session: NopySession, cubes: Record, config: NopyConfig): Promise; +/** + * Determines the appropriate workflow based on options + */ +export declare function runWorkflow(sessionPath: string | undefined, cubes: Record, config: NopyConfig, options?: WorkflowOptions, replaySession?: NopySession): Promise; diff --git a/packages/nopy/dist/nopy.workflow.js b/packages/nopy/dist/nopy.workflow.js new file mode 100644 index 0000000..7ec7c17 --- /dev/null +++ b/packages/nopy/dist/nopy.workflow.js @@ -0,0 +1,149 @@ +/** + * 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); +} diff --git a/packages/nopy/docs/API.md b/packages/nopy/docs/API.md new file mode 100644 index 0000000..2cf2b22 --- /dev/null +++ b/packages/nopy/docs/API.md @@ -0,0 +1,655 @@ +# Nopy API Reference + +This document describes the public API for the nopy package. + +--- + +## Table of Contents + +- [Main Module](#main-module) +- [Cubes Module](#cubes-module) +- [Executor Module](#executor-module) +- [Builder Module](#builder-module) +- [Workflow Module](#workflow-module) +- [Session Module](#session-module) +- [Config Module](#config-module) +- [Prompts Module](#prompts-module) + +--- + +## Main Module + +### `nopy(options?)` + +Main entry point for nopy deployments. + +```typescript +import { nopy } from '@bitstack/nopy'; + +const result = await nopy({ + useDefaults: false, + dryRun: true, +}); +``` + +**Parameters:** + +| Name | Type | Default | Description | +|------|------|---------|-------------| +| `useDefaults` | `boolean` | `false` | Skip variable prompts, use defaults | +| `useAuthKey` | `boolean` | `false` | Force SSH key authentication | +| `saveSession` | `string` | - | Path to save session file | +| `loadSession` | `string` | - | Path to load session for replay | +| `dryRun` | `boolean` | `false` | Show execution plan without running | +| `parallel` | `boolean` | `false` | Execute independent cubes in parallel | +| `continueOnError` | `boolean` | `false` | Continue after failures | +| `jsonOutput` | `boolean` | `false` | Output results as JSON | + +**Returns:** `Promise` + +```typescript +interface NopyResult { + success: boolean; + results: ExecutionResult[]; + summary: { + total: number; + successful: number; + failed: number; + totalDuration: number; + }; +} +``` + +--- + +## Cubes Module + +The cubes module provides types and functions for working with deployment units. + +### Types + +#### `Cube` + +A fully loaded cube with filesystem location. + +```typescript +interface Cube { + key: string; // Unique identifier + name: string; // Human-readable name + dir: string; // Absolute path to cube directory + dependencies: string[]; + schema: Schema; + defaults: () => z.infer; + before: Hook[]; + after: Hook[]; +} +``` + +#### `Manifest` + +Cube manifest (used in `*.manifest.mjs` files). + +```typescript +interface Manifest { + name: string; + key: string; + dependencies: string[]; + schema: Schema; + defaults: () => z.infer; + before: Hook[]; + after: Hook[]; +} +``` + +#### `Hook` + +Hook function for before/after cube execution. See [Cube Hooks](HOOKS.md) for more details. + +```typescript +type Hook = ( + ctx: HookContext, + params: z.infer +) => void | Promise; + +interface HookContext { + /** + * Schedules another cube for execution. + * @param key - The unique identifier or path of the cube. + * @param params - Variables to pass to the cube. + */ + exec: (key: string, params: CubeVariables) => Promise | void; +} +``` + +### Functions + +#### `loadCubes()` + +Loads all cubes from discovered cube directories. + +```typescript +const { cubes, errors } = await loadCubes(); +``` + +**Returns:** `Promise` + +```typescript +interface LoadResult { + cubes: Record; + errors: string[]; +} +``` + +#### `resolveDependencies(cubes, selectedCubeNames)` + +Resolves all transitive dependencies for selected cubes. + +```typescript +const order = resolveDependencies(cubes, ['apt-all']); +// Returns: ['apt:essentials', 'apt-more', 'apt-all'] +``` + +**Parameters:** + +| Name | Type | Description | +|------|------|-------------| +| `cubes` | `Record` | Map of all available cubes | +| `selectedCubeNames` | `string[]` | Cubes to resolve | + +**Returns:** `string[]` - Cube names in execution order + +**Throws:** `Error` if cube not found or circular dependency detected + +#### `buildExecutionStages(cubes, selectedCubeNames)` + +Groups cubes into stages for parallel execution. + +```typescript +const stages = buildExecutionStages(cubes, ['apt-all', 'docker']); +// Returns: [['apt:essentials'], ['apt-more', 'docker'], ['apt-all']] +``` + +**Returns:** `string[][]` - Array of stages + +#### `createManifest(options)` + +Factory function for creating cube manifests. + +```typescript +export default createManifest({ + name: 'My Cube', + dependencies: () => [['apt:essentials']], + schema: z.object({ + VERSION: z.string().default('1.0'), + }), +}); +``` + +#### `uniqid(length?)` + +Generates a random alphanumeric string. + +```typescript +const id = uniqid(); // 'Kx7Pm' +const long = uniqid(10); // 'Kx7PmQr2Yw' +``` + +--- + +## Executor Module + +Handles pyinfra command execution. + +### Types + +#### `DeployCall` + +A deployment command ready for execution. + +```typescript +interface DeployCall { + cube: string; + host: string; + cwd: string; + command: string[]; + env: Record; + dependencies: string[]; +} +``` + +#### `ExecutionResult` + +Result of executing a deployment command. + +```typescript +interface ExecutionResult { + cube: string; + host: string; + success: boolean; + duration: number; + stdout?: string; + stderr?: string; + error?: Error; +} +``` + +#### `ExecutionOptions` + +Options for deployment execution. + +```typescript +interface ExecutionOptions { + parallel?: boolean; + concurrency?: number; + continueOnError?: boolean; + dryRun?: boolean; + onProgress?: (result: ExecutionResult, completed: number, total: number) => void; + onStart?: (cube: string, host: string) => void; +} +``` + +### Functions + +#### `executeDeployCalls(calls, options?)` + +Executes an array of deployment calls. + +```typescript +const results = await executeDeployCalls(calls, { + parallel: true, + concurrency: 4, + onProgress: (result, completed, total) => { + console.log(`${completed}/${total}`); + }, +}); +``` + +#### `outputExecutionPlan(calls, asJson?)` + +Outputs the execution plan without running. + +```typescript +outputExecutionPlan(deployCalls); // Text output +outputExecutionPlan(deployCalls, true); // JSON output +``` + +#### `summarizeResults(results)` + +Generates a summary of execution results. + +```typescript +const summary = summarizeResults(results); +// { +// total: 5, +// successful: 4, +// failed: 1, +// totalDuration: 12345, +// failures: [{ cube: 'docker', ... }] +// } +``` + +--- + +## Builder Module + +Constructs deployment commands. + +### `buildDeployCalls(cubeNames, hosts, context)` + +Builds deployment calls for all cubes and hosts. + +```typescript +const result = await buildDeployCalls( + ['apt:essentials', 'apt-more'], + ['@docker/test'], + { + cubes, + session, + config, + authMethod: 'ssh-key', + useDefaults: true, + isSessionReplay: false, + } +); +``` + +**Returns:** `Promise` + +```typescript +interface BuildResult { + deployCalls: DeployCall[]; + cubeSessions: CubeSession[]; + sessionEnv: Record; +} +``` + +--- + +## Workflow Module + +Manages interactive and replay workflows. + +### `runWorkflow(sessionPath, cubes, config, options?)` + +Runs the appropriate workflow based on options. + +```typescript +const result = await runWorkflow( + undefined, // null for interactive, path for replay + cubes, + config, + { useDefaults: false } +); +``` + +**Returns:** `Promise` + +```typescript +interface WorkflowResult { + session: NopySession; + cubesWithDependencies: string[]; + authMethod: string; + username?: string; + password?: string; + isReplay: boolean; +} +``` + +### `runInteractiveWorkflow(cubes, config, options?)` + +Runs the interactive cube selection workflow. + +### `runReplayWorkflow(sessionPath, cubes, config)` + +Runs a replay from a saved session file. + +--- + +## Session Module + +Manages session save/load operations. + +### Types + +#### `NopySession` + +Complete session configuration. + +```typescript +interface NopySession { + name?: string; + cubes: CubeSession[]; + hosts?: string[]; + auth: AuthSession; + env?: SessionVariables; +} +``` + +#### `CubeSession` + +Configuration for a single cube. + +```typescript +interface CubeSession { + key: string; + variables: SessionVariables; +} +``` + +#### `AuthSession` + +Authentication configuration. + +```typescript +interface AuthSession { + method: 'ssh-key' | 'password' | 'ssh'; + username?: string; +} +``` + +### Functions + +#### `saveSession(session, filePath)` + +Saves a session to a JSON file. + +```typescript +saveSession(session, './my-deployment.nopysession.json'); +``` + +#### `loadSession(filePath)` + +Loads a session from a JSON or MJS file. + +```typescript +const session = await loadSession('./deployment.json'); +const session = await loadSession('./deployment.mjs'); +``` + +#### `createSession(params)` + +Creates a session object from runtime data. + +```typescript +const session = createSession({ + cubes: [{ key: 'apt:essentials', variables: {} }], + hosts: ['localhost'], + auth: { method: 'ssh-key' }, +}); +``` + +#### `listSessions(dirPath?)` + +Lists all session files in a directory. + +```typescript +const sessions = listSessions('./sessions'); +// ['./sessions/deploy.session.json', './sessions/test.session.mjs'] +``` + +--- + +## Config Module + +Manages nopy configuration. + +### Types + +#### `NopyConfig` + +Configuration file structure. + +```typescript +interface NopyConfig { + hosts: string[]; + cubeDirs: string[]; + env: EnvConfig; + log?: LogConfig; +} +``` + +#### `LogConfig` + +Logging configuration. + +```typescript +interface LogConfig { + verbosity?: 'silent' | 'info' | 'verbose' | 'trace'; + debug?: boolean; +} +``` + +### Functions + +#### `loadConfig()` + +Loads configuration from `.nopyrc.json`. + +```typescript +const config = loadConfig(); +``` + +Search order: + +1. `./nopyrc.json` (local) +2. `~/.nopyrc.json` (home) + +#### `saveConfig(data, local?)` + +Saves configuration to a file. + +```typescript +saveConfig({ hosts: ['server.local'] }); // Local +saveConfig({ hosts: ['server.local'] }, false); // Home +``` + +#### `logConfigToFlags(logConfig?)` + +Converts log config to pyinfra flags. + +```typescript +logConfigToFlags({ verbosity: 'verbose', debug: true }); +// ['-vv', '--debug'] +``` + +--- + +## Prompts Module + +Interactive prompts for user input. + +### `CubeSelection(cubes)` + +Prompts user to select cubes to execute. + +```typescript +const { selectedCubes } = await CubeSelection(cubes); +``` + +### `HostSelection(hosts)` + +Prompts user to select a target host. + +```typescript +const host = await HostSelection(['server1', 'server2']); +``` + +### `AuthSelection(useAuthKey?)` + +Prompts user to select authentication method. + +```typescript +const { authMethod, username, password } = await AuthSelection(); +``` + +### `VariableAssignment(cube, env)` + +Prompts user to customize cube variables. + +```typescript +const vars = await VariableAssignment(cube, { existing: 'value' }); +``` + +### `PasswordSelection(username)` + +Prompts for password input. + +```typescript +const password = await PasswordSelection('admin'); +``` + +--- + +## CLI Usage + +```bash +# Interactive deployment +nopy install + +# With defaults (no prompts) +nopy install -D + +# SSH key auth +nopy install -K + +# Save session +nopy install -s ./my-session.json + +# Replay session +nopy install -l ./my-session.json + +# Dry run +nopy install -n + +# Parallel execution +nopy install -p + +# JSON output +nopy install -j + +# Continue on error +nopy install -c +``` + +--- + +## Creating a Cube + +### File Structure + +``` +cubes/ +└── my-cube/ + ├── my-cube.manifest.mjs + └── my-cube.deploy.py +``` + +### Manifest Example + +```javascript +// my-cube.manifest.mjs +import { createManifest } from '@bitstack/nopy'; +import { z } from 'zod'; + +export default createManifest({ + name: 'My Cube', + dependencies: () => [['apt:essentials']], + schema: z.object({ + VERSION: z.string().default('1.0').describe('Version to install'), + ENABLE_FEATURE: z.boolean().default(false), + }), + before: [ + (ctx, params) => { + console.log('Before my-cube'); + }, + ], + after: [ + (ctx, params) => { + console.log('After my-cube'); + }, + ], +}); +``` + +### Deploy Script Example + +```python +# my-cube.deploy.py +from pyinfra import host +from pyinfra.operations import apt, server + +VERSION = host.data.get('VERSION', '1.0') +ENABLE_FEATURE = host.data.get('ENABLE_FEATURE', False) + +apt.packages( + name='Install my-package', + packages=[f'my-package={VERSION}'], + update=True, +) + +if ENABLE_FEATURE: + server.shell( + name='Enable feature', + commands=['my-package --enable-feature'], + ) +``` diff --git a/packages/nopy/docs/HOOKS.md b/packages/nopy/docs/HOOKS.md new file mode 100644 index 0000000..075e473 --- /dev/null +++ b/packages/nopy/docs/HOOKS.md @@ -0,0 +1,90 @@ +# Nopy Cube Hooks + +Hooks provide a way to orchestrate deployments dynamically during the build process. They allow a cube to trigger the execution of other cubes based on its configuration or the environment. + +## Overview + +A cube manifest can define `before` and `after` hooks. These hooks are executed when the deployment plan is being built. + +- **`before` hooks**: Executed *before* the current cube is added to the deployment sequence. +- **`after` hooks**: Executed *after* the current cube is added to the deployment sequence. + +## Specification + +Hooks are defined as an array of functions in the cube manifest. + +```javascript +import { z } from 'zod'; +import { cubes } from '@bitstack/nopy'; + +export default cubes.Manifest({ + name: 'my-cube', + schema: z.object({ + SETUP_DB: z.boolean().default(false), + }), + before: [ + async ({ exec }, params) => { + if (params.SETUP_DB) { + // This will run BEFORE my-cube + await exec('db:setup', { TYPE: 'postgres' }); + } + } + ], + after: [ + ({ exec }, params) => { + // This will run AFTER my-cube + console.log('Finished setting up my-cube'); + } + ] +}); +``` + +### Hook Function Signature + +Each hook function receives two arguments: + +1. **`context`**: An object containing: + - `exec(cubeKey: string, params: Record)`: A function to schedule another cube for execution. +2. **`params`**: The final, validated variables for the current cube (including defaults and user-provided values). + +Hooks can be synchronous or asynchronous (returning a `Promise`). + +## Mechanics + +### Sequential Execution + +In sequential execution mode (the default), cubes added via hooks will follow the order in which they were pushed to the deployment plan: + +1. Cubes from `before` hooks. +2. The current cube itself. +3. Cubes from `after` hooks. + +### Parallel Execution + +In parallel execution mode, cubes added via hooks **do not automatically inherit dependencies**. + +If a `before` hook calls `exec('setup-cube')`, it ensures that `setup-cube` is placed earlier in the deployment plan, but for parallel execution, you should still ensure that dependencies are correctly specified if one cube relies on another's completion. + +### Variable Passing + +When you call `exec(cubeKey, params)` within a hook: + +1. The `params` provided are merged with the current environment variables. +2. These variables are passed to the target cube, preventing it from prompting the user for those same variables. + +### Use Cases + +- **Conditional Setup**: Running a setup cube only if a specific variable is set. +- **Environment Preparation**: Ensuring a user exists or a directory is created before the main cube runs. +- **Cleanup/Notification**: Running a task after a cube deployment finishes. + +## Comparison with Dependencies + +| Feature | Dependencies | Hooks | +| :--- | :--- | :--- | +| **Declaration** | Static (`dependencies: () => [['id']]`) | Dynamic (`before: [...]`) | +| **Execution Order** | Guaranteed before dependent | `before` (before) or `after` (after) | +| **Variable Passing** | Inherited from env | Explicitly passed via `exec()` | +| **Conditionality** | Always run | Can be conditional based on logic | + +Use **dependencies** for static requirements and **hooks** for dynamic orchestration and explicit parameter passing. diff --git a/packages/nopy/docs/SESSION_FORMAT.md b/packages/nopy/docs/SESSION_FORMAT.md new file mode 100644 index 0000000..bac729d --- /dev/null +++ b/packages/nopy/docs/SESSION_FORMAT.md @@ -0,0 +1,323 @@ +# Nopy Session Format + +Nopy supports two session file formats: **JSON** and **MJS** (ES Module JavaScript). + +## Supported Formats + +### JSON Format (`.session.json`) + +Traditional JSON format for session files: + +```json +{ + "version": "1.0.0", + "timestamp": "2025-10-15T00:00:00.000Z", + "cubes": [ + { + "key": "runtime:nodevm", + "variables": { + "VERSION": "22", + "USER": "myuser" + } + } + ], + "hosts": ["@ssh/myhost.local"], + "auth": { + "method": "password", + "username": "admin" + }, + "env": {} +} +``` + +**Limitations:** +- No comments allowed (pure JSON) +- Cannot use dynamic values or computation +- No code reuse or imports + +### MJS Format (`.session.mjs`) - **Recommended** + +JavaScript module format with full ES Module support: + +```javascript +// Nopy Session Configuration +// Comments are fully supported! + +// You can import values from other files +import { commonHosts } from './common-config.mjs'; + +// You can use dynamic values +const timestamp = new Date().toISOString(); +const nodeVersion = process.env.NODE_VERSION || "22"; + +export default { + version: "1.0.0", + timestamp, + + cubes: [ + // Inline comments for each cube + { + key: "runtime:nodevm", + variables: { + VERSION: nodeVersion, // Dynamic value + USER: "myuser", + ALIAS: "nodelts", + GLOBAL_PACKAGES: "pm2 yarn" + } + }, + + // Add more cubes... + ], + + hosts: commonHosts, // Imported from another file + + auth: { + method: "password", + username: "admin" + }, + + env: { + NODE_ENV: process.env.NODE_ENV || "production" + } +}; +``` + +**Advantages:** +- ✅ **Comments** - Document your configuration inline +- ✅ **Dynamic values** - Use environment variables, compute values +- ✅ **Code reuse** - Import common configurations from other files +- ✅ **Parameterization** - Easily parameterize sessions from external tools +- ✅ **Type safety** - Use JSDoc or TypeScript for validation +- ✅ **Computation** - Calculate values, filter arrays, etc. + +## Advanced MJS Examples + +### Using Environment Variables + +```javascript +export default { + version: "1.0.0", + timestamp: new Date().toISOString(), + + cubes: [ + { + key: "typestack-install", + variables: { + REPO: process.env.GIT_REPO || "git@github.com:org/repo.git", + USER: process.env.DEPLOY_USER || "admin", + APP: process.env.APP_NAME || "myapp", + ENV: process.env.NODE_ENV || "production" + } + } + ], + + hosts: [process.env.TARGET_HOST || "@ssh/localhost"], + + auth: { + method: "password", + username: process.env.SSH_USER || "admin" + } +}; +``` + +### Conditional Cube Inclusion + +```javascript +const isDevelopment = process.env.NODE_ENV === 'development'; + +export default { + version: "1.0.0", + timestamp: new Date().toISOString(), + + cubes: [ + { + key: "runtime:docker", + variables: { DISTRO: "debian" } + }, + + // Only include in development + ...(isDevelopment ? [{ + key: "debug-tools", + variables: { INSTALL_GDB: true } + }] : []) + ], + + hosts: ["@ssh/myhost.local"], + auth: { method: "ssh" } +}; +``` + +### Importing Common Configuration + +**common-config.mjs:** +```javascript +export const productionHosts = [ + "@ssh/prod-server-1.local", + "@ssh/prod-server-2.local" +]; + +export const stagingHosts = [ + "@ssh/staging.local" +]; + +export const commonCubes = [ + { + key: "apt:essentials", + variables: { UPDATE: true } + }, + { + key: "runtime:docker", + variables: { DISTRO: "debian" } + } +]; +``` + +**my-session.session.mjs:** +```javascript +import { productionHosts, commonCubes } from './common-config.mjs'; + +export default { + version: "1.0.0", + timestamp: new Date().toISOString(), + + cubes: [ + ...commonCubes, // Include common cubes + { + key: "typestack-install", + variables: { + REPO: "git@github.com:myorg/myapp.git", + USER: "appuser", + APP: "myapp" + } + } + ], + + hosts: productionHosts, // Use imported hosts + + auth: { + method: "password", + username: "admin" + } +}; +``` + +### Programmatic Generation + +You can even generate sessions programmatically from other tools: + +**generate-session.mjs:** +```javascript +import fs from 'fs'; + +function generateSession(config) { + const cubes = config.services.map(service => ({ + key: "typestack-install", + variables: { + REPO: service.repo, + USER: config.user, + APP: service.name, + ENV: config.environment + } + })); + + const session = { + version: "1.0.0", + timestamp: new Date().toISOString(), + cubes, + hosts: config.hosts, + auth: { + method: "password", + username: config.user + } + }; + + const content = `export default ${JSON.stringify(session, null, 2)};`; + fs.writeFileSync('generated.session.mjs', content); +} + +// Generate from external configuration +generateSession({ + user: "deploy", + environment: "production", + services: [ + { name: "api", repo: "git@github.com:org/api.git" }, + { name: "web", repo: "git@github.com:org/web.git" } + ], + hosts: ["@ssh/prod.local"] +}); +``` + +## Loading Sessions + +Both formats are loaded the same way: + +```javascript +import { loadSession } from '@bitstack/nopy'; + +// Load JSON +const jsonSession = await loadSession('./my-session.session.json'); + +// Load MJS +const mjsSession = await loadSession('./my-session.session.mjs'); +``` + +The file extension determines which loader to use. + +## Migration from JSON to MJS + +To convert an existing JSON session to MJS: + +1. Rename the file from `.session.json` to `.session.mjs` +2. Add `export default` before the configuration object +3. Remove quotes from property keys (optional) +4. Add comments and dynamic values as needed + +**Before (JSON):** +```json +{ + "version": "1.0.0", + "cubes": [...] +} +``` + +**After (MJS):** +```javascript +export default { + version: "1.0.0", + cubes: [...] +}; +``` + +## Best Practices + +1. **Use MJS for new sessions** - Take advantage of comments and flexibility +2. **Document your cubes** - Add comments explaining what each cube does +3. **Use environment variables** - Make sessions reusable across environments +4. **Extract common config** - Share configuration across multiple sessions +5. **Version control** - Both formats work well with git +6. **Validate at runtime** - The loader validates the structure regardless of format + +## Session Schema + +Both formats must export/contain an object with this structure: + +```typescript +interface NopySession { + version: string; // Session format version + timestamp: string; // ISO timestamp + cubes: CubeSession[]; // Array of cube configurations + hosts: string[]; // Target hosts + auth: AuthSession; // Authentication configuration + env?: Record; // Global environment variables +} + +interface CubeSession { + key: string; // Cube identifier + variables: Record; // Cube-specific variables +} + +interface AuthSession { + method: 'ssh-key' | 'password' | 'ssh'; + username?: string; +} +``` diff --git a/packages/nopy/example.nopysession.json b/packages/nopy/example.nopysession.json new file mode 100644 index 0000000..95bdc49 --- /dev/null +++ b/packages/nopy/example.nopysession.json @@ -0,0 +1,18 @@ +{ + "name": "Example Deployment Session", + "cubes": [ + { + "key": "apt:essentials", + "variables": { + "UPDATE": true + } + } + ], + "hosts": ["@docker/nopy-test-ubuntu"], + "env": { + "KEY_DIR": "../../vault/tmp" + }, + "auth": { + "method": "ssh-key" + } +} diff --git a/packages/nopy/package.json b/packages/nopy/package.json new file mode 100644 index 0000000..b73acc9 --- /dev/null +++ b/packages/nopy/package.json @@ -0,0 +1,44 @@ +{ + "name": "@bitstack/nopy", + "description": "A system to simplify pyinfra script management and execution.", + "type": "module", + "version": "1.0.0", + "private": true, + "author": "bitsquare", + "bin": "./dist/nopy.cli.js", + "exports": { + ".": "./dist/index.js" + }, + "scripts": { + "clean": "rm -rf dist", + "build": "tsgo && chmod +x dist/nopy.cli.js && npm link", + "build:legacy": "tsc && chmod +x dist/nopy.cli.js && npm link", + "prepublishOnly": "npm run build", + "nopy": "node --loader ts-node/esm src/nopy.cli.ts", + "debug": "node --inspect-brk --loader ts-node/esm src/nopy.cli.ts", + "test": "vitest run", + "test:integration": "vitest run --pool=forks", + "test:watch": "vitest" + }, + "files": ["dist/"], + "dependencies": { + "@logtape/logtape": "^0.8.0", + "commander": "^13.1.0", + "enquirer": "^2.4.1", + "execa": "9.5.2", + "fuzzy": "^0.1.3", + "inquirer": "8.2.4", + "inquirer-checkbox-plus-prompt": "^1.0.1", + "ts-node": ">=10.9.1", + "typescript": ">=5.6.3", + "yaml": "^2.8.2", + "zod": "^3.24.1", + "zx": "^8.3.0" + }, + "devDependencies": { + "@types/inquirer": "^8.2.10", + "@types/node": "^20.0.0", + "@types/uniqid": "^5.3.4", + "vitest": "^1.6.0" + } +} diff --git a/packages/nopy/src/cubes/dependencies.ts b/packages/nopy/src/cubes/dependencies.ts new file mode 100644 index 0000000..dcd9041 --- /dev/null +++ b/packages/nopy/src/cubes/dependencies.ts @@ -0,0 +1,140 @@ +/** + * Dynamic dependency resolution for cubes + * @module cubes/dependencies + */ + +import { getLogger } from '@logtape/logtape'; +import type { Variables } from '../nopy.common.js'; +import type { NopyConfig } from '../nopy.config.js'; +import type { DeployCall } from '../nopy.executor.js'; +import { VariableAssignment } from '../nopy.prompts.js'; +import { type CubeSession, type NopySession } from '../nopy.session.js'; +import type { Cube, CubeVariables, DependencySpec, HookContext } from './types.js'; + +const log = getLogger(['nopy', 'resolution']); + +/** + * Context for the resolution process + */ +export class BuildContext { + public readonly deployCalls: DeployCall[] = []; + public readonly cubeSessions: CubeSession[] = []; + private readonly resolvedCubes = new Set(); + + constructor( + public readonly allCubes: Record, + public readonly variables: Variables, + public readonly session: NopySession, + public readonly config: NopyConfig, + public readonly auth: { + method: string; + username?: string; + password?: string; + }, + public readonly options: { + useDefaults?: boolean; + isSessionReplay?: boolean; + } = {} + ) {} + + /** + * Resolves a cube, its dependencies, and hooks recursively + */ + public async resolveCube(cubeId: string, host: string, overrides: CubeVariables = {}): Promise { + 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: HookContext = { + 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 + */ + private buildDeployCall(cube: Cube, host: string): void { + const cubeId = cube.id; + const callKey = `${cubeId}:${host}`; + + if (this.resolvedCubes.has(callKey)) return; + + const parts: string[] = []; + 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); + } +} diff --git a/packages/nopy/src/cubes/factories.ts b/packages/nopy/src/cubes/factories.ts new file mode 100644 index 0000000..647db37 --- /dev/null +++ b/packages/nopy/src/cubes/factories.ts @@ -0,0 +1,30 @@ +/** + * 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: Pick, 'name'> & Partial, 'name'>> +): Manifest { + 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'; diff --git a/packages/nopy/src/cubes/index.ts b/packages/nopy/src/cubes/index.ts new file mode 100644 index 0000000..19d0379 --- /dev/null +++ b/packages/nopy/src/cubes/index.ts @@ -0,0 +1,42 @@ +/** + * Nopy Cubes Module + * + * Self-contained deployment units for pyinfra automation. + * + * @module cubes + */ + +// Types +export { + Cube, + Manifest, +} from './types.js'; + +export type { + Hook, + HookContext, + LoadResult, + CubeVariables, + DependencySpec, +} from './types.js'; + +// Factory functions +export { + createManifest, + manifest, +} from './factories.js'; + +// Loader +export { + loadCubes, + findCubeDirectories, + getCube, +} from './loader.js'; + +// Dependencies +export { + BuildContext, +} from './dependencies.js'; + +// Utilities +export { uniqid } from './utils.js'; diff --git a/packages/nopy/src/cubes/loader.ts b/packages/nopy/src/cubes/loader.ts new file mode 100644 index 0000000..d0a4501 --- /dev/null +++ b/packages/nopy/src/cubes/loader.ts @@ -0,0 +1,124 @@ +/** + * 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, type LoadResult, type Manifest } 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(): string[] { + 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: Manifest): string | undefined { + 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(): Promise { + const cubesFolders = findCubeDirectories(); + const cubes: Record = {}; + const errors: string[] = []; + + async function scanDirectory(currentDir: string, baseDir: string): Promise { + 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 as Manifest; + + 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: string): Promise { + const { cubes } = await loadCubes(); + return cubes[cubeName]; +} diff --git a/packages/nopy/src/cubes/types.ts b/packages/nopy/src/cubes/types.ts new file mode 100644 index 0000000..eee3ff5 --- /dev/null +++ b/packages/nopy/src/cubes/types.ts @@ -0,0 +1,116 @@ +/** + * Type definitions for Nopy cubes + * @module cubes/types + */ + +import { z } from 'zod'; + +/** + * Variables that can be passed to a cube + */ +export type CubeVariables = Record; + +/** + * 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; +} + +/** + * Hook function type for before/after cube execution + */ +export type Hook = ( + ctx: HookContext, + variables: z.infer +) => void | Promise; + +/** + * User-defined specification for a cube + */ +export interface Manifest { + /** 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) => DependencySpec[]; + /** Hooks to run before cube execution */ + before?: Hook[]; + /** Hooks to run after cube execution */ + after?: Hook[]; +} + +/** + * Factory function and namespace for Manifest + */ +export function Manifest( + opts: Pick, 'name'> & Partial, 'name'>> +): Manifest { + return { + id: opts.id ?? '', + name: opts.name, + schema: opts.schema ?? (z.object({}) as unknown as Schema), + dependencies: opts.dependencies, + before: opts.before ?? [], + after: opts.after ?? [], + }; +} + +export namespace Manifest { + /** + * Internal create helper + */ + export function create( + opts: Pick, 'name'> & Partial, 'name'>> + ): Manifest { + return Manifest(opts); + } +} + +/** + * A fully loaded cube with its filesystem location and runtime state + */ +export class Cube { + constructor( + public readonly manifest: Manifest, + public readonly dir: string, + public readonly deployScript: string + ) {} + + get id(): string { + return this.manifest.id; + } + + get name(): string { + return this.manifest.name; + } + + /** + * Returns default values for the cube's schema + */ + getDefaults(): z.infer { + try { + return this.manifest.schema.parse({}); + } catch { + return {} as z.infer; + } + } +} + +/** + * Result of loading cubes from the filesystem + */ +export interface LoadResult { + /** Map of cube key to Cube object */ + cubes: Record; + /** List of errors encountered during loading */ + errors: string[]; +} diff --git a/packages/nopy/src/cubes/utils.ts b/packages/nopy/src/cubes/utils.ts new file mode 100644 index 0000000..bbaef79 --- /dev/null +++ b/packages/nopy/src/cubes/utils.ts @@ -0,0 +1,38 @@ +/** + * 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): string { + 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: string[] = []; + + 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(''); +} diff --git a/packages/nopy/src/index.ts b/packages/nopy/src/index.ts new file mode 100644 index 0000000..919b2b3 --- /dev/null +++ b/packages/nopy/src/index.ts @@ -0,0 +1,86 @@ +/** + * 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'; +export type { NopyOptions, NopyResult } from './nopy.main.js'; + +// Executor +export { + executeDeployCalls, + outputExecutionPlan, + summarizeResults, +} from './nopy.executor.js'; +export type { + DeployCall, + ExecutionResult, + ExecutionOptions, +} from './nopy.executor.js'; + +// Workflow +export { + runWorkflow, + runInteractiveWorkflow, + runReplayWorkflow, + runSessionReplayWorkflow, +} 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'; diff --git a/packages/nopy/src/nopy.cli.ts b/packages/nopy/src/nopy.cli.ts new file mode 100644 index 0000000..203a416 --- /dev/null +++ b/packages/nopy/src/nopy.cli.ts @@ -0,0 +1,151 @@ +#!/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 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 ' 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 ', 'Run a specific session from history by ID') + .option('-s, --save-session ', 'Save session to file for later replay') + .option('-l, --load-session ', '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: { session: import('./nopy.session.js').NopySession } | undefined; + + 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(); diff --git a/packages/nopy/src/nopy.common.ts b/packages/nopy/src/nopy.common.ts new file mode 100644 index 0000000..bf6efb4 --- /dev/null +++ b/packages/nopy/src/nopy.common.ts @@ -0,0 +1,41 @@ +/** + * Environment variable configuration + */ +export type TVariables = Record; + +export namespace Variables { + export type ArtefactId = string; + export type Scope = 'defaults' | 'prompts' | 'params'; +} + +export class Variables { + /** @summary env as configured in cube or session script */ + defaults: Record = {}; + /** @summary env as configured via prompts */ + prompts: Record = {}; + /** @summary env as handed via params (on hook calls) */ + params: Record = {}; + + constructor(readonly global: TVariables = {}) {} + + assign(artefactId: Variables.ArtefactId, scope: Variables.Scope, values: TVariables = {}) { + console.log('Assigning', artefactId, scope, values); + if (!this[scope][artefactId]) { + this[scope][artefactId] = values; + } else { + Object.assign(this[scope][artefactId], values); + } + } + + get(artefactId: Variables.ArtefactId, scope?: Variables.Scope): TVariables { + if (scope) { + return this[scope][artefactId] || {}; + } + return { + ...this.global, + ...this.defaults[artefactId], + ...this.prompts[artefactId], + ...this.params[artefactId], + }; + } +} diff --git a/packages/nopy/src/nopy.config.ts b/packages/nopy/src/nopy.config.ts new file mode 100644 index 0000000..d169ece --- /dev/null +++ b/packages/nopy/src/nopy.config.ts @@ -0,0 +1,378 @@ +/** + * Configuration loading and management + * @module nopy.config + */ + +import fs from 'node:fs'; +import path from 'node:path'; +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 { + /** 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; +} + +/** + * Default configuration + */ +const DEFAULT_CONFIG: NopyConfig = { + 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(): string[] { + const configPaths: string[] = []; + 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: unknown, + childValue: unknown, + strategy: ResolutionStrategy +): unknown { + // 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: Record = { ...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: string): boolean { + 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: unknown, configDir: string): unknown { + 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: Record = {}; + 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: (keyof NopyConfig)[] = ['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: NopyConfigFile, configPath: string): NopyConfigFile { + const configDir = path.dirname(configPath); + const resolved: NopyConfigFile = {}; + + for (const [key, value] of Object.entries(config)) { + if (key === 'resolution') { + // Don't resolve the resolution config itself + resolved[key] = value as ResolutionConfig; + } else if (PATH_PROPERTIES.includes(key as keyof NopyConfig)) { + // Only resolve paths for known path properties + resolved[key as keyof NopyConfigFile] = resolveRelativePaths(value, configDir) as any; + } else { + // Copy other properties as-is (including hosts) + resolved[key as keyof NopyConfigFile] = value as any; + } + } + + return resolved; +} + +/** + * Merges a child config into a parent config + */ +function mergeConfigs(parent: NopyConfig, childFile: NopyConfigFile): NopyConfig { + const resolution = childFile.resolution || {}; + const result: Record = { ...parent }; + + for (const [key, value] of Object.entries(childFile)) { + if (key === 'resolution') continue; // Skip resolution property itself + + const strategy = resolution[key as keyof NopyConfig] || 'merge'; + if (key in result) { + result[key] = mergeValue(result[key], value, strategy); + } else { + result[key] = value; + } + } + + return result as unknown as NopyConfig; +} + +/** + * 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(): NopyConfig { + 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: NopyConfig = { ...DEFAULT_CONFIG }; + + for (const configPath of configPaths) { + try { + const content = fs.readFileSync(configPath, 'utf-8'); + const rawConfig = JSON.parse(content) as NopyConfigFile; + // 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(): string[] { + 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: Partial, configPath?: string): void { + const savePath = configPath || path.resolve(process.cwd(), CONFIG_FILENAME); + + // Try to load existing config from this specific file + let existing: Partial = {}; + 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?: LogConfig): string[] { + const flags: string[] = []; + 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; +} diff --git a/packages/nopy/src/nopy.cubes.ts b/packages/nopy/src/nopy.cubes.ts new file mode 100644 index 0000000..879f359 --- /dev/null +++ b/packages/nopy/src/nopy.cubes.ts @@ -0,0 +1,29 @@ +/** + * 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, +}; + +// Re-export types for direct access +export type { + Hook, + HookContext, + Cube, + Manifest, + LoadResult, + CubeVariables, +} from './cubes/index.js'; diff --git a/packages/nopy/src/nopy.executor.ts b/packages/nopy/src/nopy.executor.ts new file mode 100644 index 0000000..dca19a1 --- /dev/null +++ b/packages/nopy/src/nopy.executor.ts @@ -0,0 +1,222 @@ +/** + * Pyinfra command execution + * @module nopy.executor + */ + +import { getLogger } from '@logtape/logtape'; +import { execa } from 'execa'; +import type { DependencySpec } from './cubes/types.js'; + +const log = getLogger(['nopy', 'executor']); + +/** + * 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; + /** 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; +} + +/** + * Executes a single deployment call + * + * @param call - The deployment call to execute + * @returns Execution result + */ +async function executeCall(call: DeployCall): Promise { + 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: DeployCall[], asJson?: boolean): void { + 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: DeployCall[], + options: ExecutionOptions = {} +): Promise { + 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: ExecutionResult[] = []; + + 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: ExecutionResult[]): { + total: number; + successful: number; + failed: number; + totalDuration: number; + failures: ExecutionResult[]; +} { + 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, + }; +} diff --git a/packages/nopy/src/nopy.history.ts b/packages/nopy/src/nopy.history.ts new file mode 100644 index 0000000..7167610 --- /dev/null +++ b/packages/nopy/src/nopy.history.ts @@ -0,0 +1,224 @@ +/** + * Session history management + * @module nopy.history + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import type { NopySession } from './nopy.session.js'; + +/** Default number of sessions to keep in history */ +export const DEFAULT_HISTORY_SIZE = 10; + +/** History file name */ +export 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 function getHistoryPath(): string { + 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(): SessionHistory { + const historyPath = getHistoryPath(); + + if (!fs.existsSync(historyPath)) { + return { entries: [] }; + } + + try { + const content = fs.readFileSync(historyPath, 'utf-8'); + return JSON.parse(content) as SessionHistory; + } catch { + return { entries: [] }; + } +} + +/** + * Saves the session history to disk + * + * @param history - The history to save + */ +export function saveHistory(history: SessionHistory): void { + 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: NopySession, timestamp: string): string { + 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(): string { + 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: NopySession, + maxEntries: number = DEFAULT_HISTORY_SIZE +): HistoryEntry { + const history = loadHistory(); + const timestamp = new Date().toISOString(); + + const entry: HistoryEntry = { + 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(): HistoryEntry | undefined { + 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: string): HistoryEntry | undefined { + 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(): HistoryEntry[] { + const history = loadHistory(); + return history.entries; +} + +/** + * Clears all session history + */ +export function clearHistory(): void { + 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: string): boolean { + 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: HistoryEntry[]): string { + 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'); +} diff --git a/packages/nopy/src/nopy.main.ts b/packages/nopy/src/nopy.main.ts new file mode 100644 index 0000000..04e9295 --- /dev/null +++ b/packages/nopy/src/nopy.main.ts @@ -0,0 +1,228 @@ +/** + * Main entry point for nopy + * @module nopy.main + */ + +import { type LogRecord, 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 { type ExecutionResult, executeDeployCalls, summarizeResults } from './nopy.executor.js'; +import { DEFAULT_HISTORY_SIZE, addToHistory } from './nopy.history.js'; +import { type NopySession, saveSession } from './nopy.session.js'; +import { runWorkflow } from './nopy.workflow.js'; + +/** + * Configures the logtape logger for console output + */ +function configureLogtape(): void { + configure({ + sinks: { + console: (() => { + const formatter = getAnsiColorFormatter(); + return (record: LogRecord) => { + const formatted = formatter(record); + if (typeof formatted === 'string') { + const msg = formatted.replace(/\r?\n$/, ''); + const props = record.properties as Record; + 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: import('./nopy.config.js').NopyConfig, opts: { continueOnError: boolean }): void { + const configPaths = getConfigPaths(); + const cwd = process.cwd(); + + const lines: string[] = ['']; + 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 ? '' : ''}`); + } + } + + lines.push(''); + console.log(lines.join('\n')); +} + +/** + * 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 async function nopy(opts: NopyOptions = {}): Promise { + 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: NopySession = { + ...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, + }, + }; +} diff --git a/packages/nopy/src/nopy.prompts.ts b/packages/nopy/src/nopy.prompts.ts new file mode 100644 index 0000000..771c413 --- /dev/null +++ b/packages/nopy/src/nopy.prompts.ts @@ -0,0 +1,204 @@ +/** + * 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'; +import type { Cube } from './cubes/index.js'; +import type { Variables } from './nopy.common.js'; + +// Register the checkbox-plus prompt type for filterable multi-select +inquirer.registerPrompt('checkbox-plus', CheckboxPlus); + +interface CubeChoice { + name: string; + value: string; + short: string; +} + +/** + * Prompts the user to select cubes to execute with filtering support + */ +export async function CubeSelection( + cubes: Record +): Promise<{ selectedCubes: string[] }> { + const cubeChoices: CubeChoice[] = 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: unknown, input: string | undefined) => { + const searchTerm = input || ''; + if (!searchTerm) return Promise.resolve(cubeChoices); + const results = fuzzy.filter(searchTerm, cubeChoices, { + extract: (choice: CubeChoice) => choice.name, + }); + return Promise.resolve(results.map((r) => r.original)); + }, + }, + ]); + + return { selectedCubes: answers.selectedCubes }; +} + +export async function AuthSelection(useAuthKey?: boolean): Promise<{ + authMethod: string; + username?: string; + password?: string; +}> { + 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 as { authMethod: string; username?: string; password?: string }; +} + +export async function PasswordSelection(username: string): Promise { + const { password } = await inquirer.prompt([ + { + type: 'password', + name: 'password', + message: `Enter password for ${username}:`, + }, + ]); + return password; +} + +export async function HostSelection(hosts: string[]): Promise { + 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: unknown, zodType: z.ZodTypeAny): unknown { + 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; +} + +interface FormChoice { + name: string; + message: string; + initial: string; +} + +export async function VariableAssignment( + cube: Cube, + variables: Variables +) { + const schema = cube.manifest.schema.shape; + const defaults = cube.getDefaults(); + const variablesToConfigure: Record = {}; + + 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: FormChoice[] = 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 as any).Form({ + name: 'variables', + message: `[${cube.id}] ${cube.name}\n (↑↓ navigate, Enter to submit)`, + choices, + }); + + try { + const result = await form.run(); + const coercedResult: Record = {}; + 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 + } +} diff --git a/packages/nopy/src/nopy.session.ts b/packages/nopy/src/nopy.session.ts new file mode 100644 index 0000000..5f63c0d --- /dev/null +++ b/packages/nopy/src/nopy.session.ts @@ -0,0 +1,246 @@ +/** + * Session management for saving and replaying deployments + * @module nopy.session + */ + +import fs from 'node:fs'; +import path from 'node:path'; +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; + +/** + * 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; + // Note: password is intentionally excluded for security +} + +/** + * 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 function saveSession(session: NopySession, filePath: string): void { + 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: string): Promise { + const absolutePath = path.resolve(filePath); + const fileUrl = `file://${absolutePath}`; + + try { + const module = (await import(fileUrl)) as { default?: NopySession }; + 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: string): NopySession { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content) as NopySession; +} + +/** + * 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: string): Promise { + if (!fs.existsSync(filePath)) { + throw new Error(`Session file not found: ${filePath}`); + } + + const ext = path.extname(filePath); + let session: NopySession; + + 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: string = process.cwd()): string[] { + 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: { + name?: string; + cubes: CubeSession[]; + hosts: string[]; + auth: AuthSession; + env?: TVariables; +}): NopySession { + 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: Record +): Record { + const internalKeys = ['customize']; + const filtered: Record = {}; + + 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: Record, + envVariables: Record +): { env: Record; cubeVars: Record } { + const env: Record = {}; + const cubeVars: Record = {}; + + for (const [key, value] of Object.entries(allVariables)) { + if (key in envVariables) { + env[key] = value; + } else { + cubeVars[key] = value; + } + } + + return { env, cubeVars }; +} diff --git a/packages/nopy/src/nopy.workflow.ts b/packages/nopy/src/nopy.workflow.ts new file mode 100644 index 0000000..232abf7 --- /dev/null +++ b/packages/nopy/src/nopy.workflow.ts @@ -0,0 +1,220 @@ +/** + * Workflow logic for interactive and replay modes + * @module nopy.workflow + */ + +import { getLogger } from '@logtape/logtape'; +import type { Cube } from './cubes/index.js'; +import type { NopyConfig } from './nopy.config.js'; +import { AuthSelection, CubeSelection, HostSelection, PasswordSelection } from './nopy.prompts.js'; +import { type AuthSession, type NopySession, createSession, loadSession } from './nopy.session.js'; + +const log = getLogger(['nopy', 'workflow']); + +/** + * 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 async function runInteractiveWorkflow( + cubes: Record, + config: NopyConfig, + options: WorkflowOptions = {} +): Promise { + 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' as const, username: undefined, password: undefined } + : await AuthSelection(useAuthKey); + + // Create session + const session = createSession({ + cubes: [], // Will be populated during build + hosts: [host], + auth: { + method: authResult.authMethod as AuthSession['method'], + 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: string, + cubes: Record, + config: NopyConfig +): Promise { + 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: string | undefined; + + // 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 as AuthSession['method']; + 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: NopySession, + cubes: Record, + config: NopyConfig +): Promise { + 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: string | undefined; + + // 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 as AuthSession['method']; + 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: string | undefined, + cubes: Record, + config: NopyConfig, + options: WorkflowOptions = {}, + replaySession?: NopySession +): Promise { + if (replaySession) { + return runSessionReplayWorkflow(replaySession, cubes, config); + } + if (sessionPath) { + return runReplayWorkflow(sessionPath, cubes, config); + } + return runInteractiveWorkflow(cubes, config, options); +} diff --git a/packages/nopy/tests/config.test.ts b/packages/nopy/tests/config.test.ts new file mode 100644 index 0000000..9b6c215 --- /dev/null +++ b/packages/nopy/tests/config.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for nopy.config module + */ + +import { describe, expect, it } from 'vitest'; +import { type LogConfig, logConfigToFlags } from '../src/nopy.config.js'; + +describe('logConfigToFlags', () => { + it('returns empty array for silent verbosity', () => { + const flags = logConfigToFlags({ verbosity: 'silent' }); + expect(flags).toEqual([]); + }); + + it('returns empty array for undefined config', () => { + const flags = logConfigToFlags(undefined); + expect(flags).toEqual([]); + }); + + it('returns empty array for empty config', () => { + const flags = logConfigToFlags({}); + expect(flags).toEqual([]); + }); + + it('returns -v for info verbosity', () => { + const flags = logConfigToFlags({ verbosity: 'info' }); + expect(flags).toEqual(['-v']); + }); + + it('returns -vv for verbose verbosity', () => { + const flags = logConfigToFlags({ verbosity: 'verbose' }); + expect(flags).toEqual(['-vv']); + }); + + it('returns -vvv for trace verbosity', () => { + const flags = logConfigToFlags({ verbosity: 'trace' }); + expect(flags).toEqual(['-vvv']); + }); + + it('adds --debug when debug is true', () => { + const flags = logConfigToFlags({ debug: true }); + expect(flags).toContain('--debug'); + }); + + it('combines verbosity and debug', () => { + const flags = logConfigToFlags({ verbosity: 'verbose', debug: true }); + expect(flags).toContain('-vv'); + expect(flags).toContain('--debug'); + expect(flags).toHaveLength(2); + }); + + it('does not add --debug when debug is false', () => { + const flags = logConfigToFlags({ verbosity: 'info', debug: false }); + expect(flags).toEqual(['-v']); + }); +}); diff --git a/packages/nopy/tests/cubes.dependencies.test.ts b/packages/nopy/tests/cubes.dependencies.test.ts new file mode 100644 index 0000000..b3695dc --- /dev/null +++ b/packages/nopy/tests/cubes.dependencies.test.ts @@ -0,0 +1,115 @@ +/** + * Tests for cubes/dependencies module (BuildContext) + */ + +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; +import { BuildContext } from '../src/cubes/dependencies.js'; +import { Cube, Manifest } from '../src/cubes/types.js'; +import { Variables } from '../src/nopy.common.js'; + +// Mock VariableAssignment to avoid hanging on prompts +vi.mock('../src/nopy.prompts.js', async () => { + const actual = await vi.importActual('../src/nopy.prompts.js'); + return { + ...actual, + VariableAssignment: vi.fn(), + }; +}); + +/** + * Helper to create a minimal cube for testing + */ +function createTestCube(id: string, dependencies?: (vars: any) => any[]): Cube { + const manifest = Manifest.create({ + id, + name: `Test ${id}`, + schema: z.object({}), + dependencies, + }); + return new Cube(manifest, `/test/${id}`, 'deploy.py'); +} + +describe('BuildContext.resolveCube', () => { + it('resolves a single cube with no dependencies', async () => { + const cubeA = createTestCube('cube-a'); + const cubes = { 'cube-a': cubeA }; + const vars = new Variables(); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + + await context.resolveCube('cube-a', 'host1'); + + expect(context.deployCalls).toHaveLength(1); + expect(context.deployCalls[0].cube).toBe('cube-a'); + expect(context.deployCalls[0].host).toBe('host1'); + }); + + it('resolves dependencies recursively', async () => { + const cubeA = createTestCube('cube-a'); + const cubeB = createTestCube('cube-b', () => ['cube-a']); + const cubes = { 'cube-a': cubeA, 'cube-b': cubeB }; + const vars = new Variables(); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + + await context.resolveCube('cube-b', 'host1'); + + expect(context.deployCalls).toHaveLength(2); + // Dependencies should be resolved BEFORE the cube that depends on them + expect(context.deployCalls[0].cube).toBe('cube-a'); + expect(context.deployCalls[1].cube).toBe('cube-b'); + }); + + it('resolves dynamic dependencies based on variables', async () => { + const cubeA = createTestCube('cube-a'); + const cubeB = createTestCube('cube-b'); + const cubeC = createTestCube('cube-c', (vars) => vars.USE_A ? ['cube-a'] : ['cube-b']); + + cubeC.manifest.schema = z.object({ USE_A: z.boolean().default(true) }); + + const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC }; + + // Test with USE_A = true + const vars1 = new Variables(); + const context1 = new BuildContext(cubes, vars1, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + await context1.resolveCube('cube-c', 'host1'); + expect(context1.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-c']); + + // Test with USE_A = false + const vars2 = new Variables(); + vars2.assign('cube-c', 'params', { USE_A: false }); + const context2 = new BuildContext(cubes, vars2, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + await context2.resolveCube('cube-c', 'host1'); + expect(context2.deployCalls.map(c => c.cube)).toEqual(['cube-b', 'cube-c']); + }); + + it('passes variables to dependencies', async () => { + const cubeA = createTestCube('cube-a'); + cubeA.manifest.schema = z.object({ VAR: z.string() }); + + const cubeB = createTestCube('cube-b', () => [['cube-a', { VAR: 'from-b' }]]); + + const cubes = { 'cube-a': cubeA, 'cube-b': cubeB }; + const vars = new Variables(); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + + await context.resolveCube('cube-b', 'host1'); + + expect(context.deployCalls[0].cube).toBe('cube-a'); + expect(context.deployCalls[0].env.VAR).toBe('from-b'); + }); + + it('avoids duplicate calls for the same cube on the same host', async () => { + const cubeA = createTestCube('cube-a'); + const cubeB = createTestCube('cube-b', () => ['cube-a']); + const cubeC = createTestCube('cube-c', () => ['cube-a', 'cube-b']); + + const cubes = { 'cube-a': cubeA, 'cube-b': cubeB, 'cube-c': cubeC }; + const vars = new Variables(); + const context = new BuildContext(cubes, vars, { cubes: [] } as any, { env: {} } as any, { method: 'ssh' }); + + await context.resolveCube('cube-c', 'host1'); + + // Execution order: cube-a, cube-b, cube-c + expect(context.deployCalls.map(c => c.cube)).toEqual(['cube-a', 'cube-b', 'cube-c']); + }); +}); diff --git a/packages/nopy/tests/cubes.factories.test.ts b/packages/nopy/tests/cubes.factories.test.ts new file mode 100644 index 0000000..8e95d41 --- /dev/null +++ b/packages/nopy/tests/cubes.factories.test.ts @@ -0,0 +1,39 @@ +/** + * Tests for cubes/factories module + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { createManifest, manifest } from '../src/cubes/factories.js'; + +describe('createManifest', () => { + it('creates manifest with basic properties', () => { + const m = createManifest({ + id: 'test-cube', + name: 'Test Cube', + }); + + expect(m.id).toBe('test-cube'); + expect(m.name).toBe('Test Cube'); + expect(m.schema).toBeDefined(); + expect(m.before).toEqual([]); + expect(m.after).toEqual([]); + }); + + it('accepts schema', () => { + const schema = z.object({ + VERSION: z.string().default('1.0'), + }); + + const m = createManifest({ + name: 'Test Cube', + schema, + }); + + expect(m.schema).toBe(schema); + }); + + it('manifest is alias for createManifest', () => { + expect(manifest).toBe(createManifest); + }); +}); diff --git a/packages/nopy/tests/cubes.loader.test.ts b/packages/nopy/tests/cubes.loader.test.ts new file mode 100644 index 0000000..23d05de --- /dev/null +++ b/packages/nopy/tests/cubes.loader.test.ts @@ -0,0 +1,138 @@ +/** + * Integration tests for cubes/loader module + */ + +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { fs } from 'zx'; +import { loadCubes } from '../src/cubes/loader.js'; + +describe('loadCubes (Integration)', () => { + let tmpDir: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nopy-test-')); + process.chdir(tmpDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.remove(tmpDir); + }); + + it('recursively discovers cubes in a nested hierarchy', async () => { + await fs.writeFile('.npcubes', ''); + await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] }); + + await fs.mkdirp('apt/base'); + await fs.mkdirp('apt/essentials'); + + await fs.writeFile( + 'apt/base/manifest.mjs', + ` + export default { + id: 'apt-base', + name: 'Apt Base' + } + ` + ); + await fs.writeFile('apt/base/deploy.py', '# deploy'); + + await fs.writeFile( + 'apt/essentials/manifest.mjs', + ` + export default { + id: 'apt:essentials', + name: 'Apt Essentials', + dependencies: () => ['apt-base'] + } + ` + ); + await fs.writeFile('apt/essentials/deploy.py', '# deploy'); + + const { cubes, errors } = await loadCubes(); + + expect(errors).toHaveLength(0); + expect(Object.keys(cubes)).toContain('apt-base'); + expect(Object.keys(cubes)).toContain('apt:essentials'); + + expect(cubes['apt-base'].name).toBe('Apt Base'); + expect(cubes['apt-base'].deployScript).toBe('deploy.py'); + expect(cubes['apt:essentials'].manifest.dependencies!({})).toContain('apt-base'); + expect(cubes['apt:essentials'].deployScript).toBe('deploy.py'); + }); + + it('handles relaxed naming conventions (*.manifest.mjs and *.deploy.py)', async () => { + await fs.writeFile('.npcubes', ''); + await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] }); + + await fs.mkdirp('custom'); + await fs.writeFile( + 'custom/my.manifest.mjs', + ` + export default { id: 'custom-cube', name: 'Custom Name' } + ` + ); + await fs.writeFile('custom/my.deploy.py', '# deploy'); + + const { cubes, errors } = await loadCubes(); + + expect(errors).toHaveLength(0); + expect(Object.keys(cubes)).toContain('custom-cube'); + expect(cubes['custom-cube'].name).toBe('Custom Name'); + expect(cubes['custom-cube'].deployScript).toBe('my.deploy.py'); + }); + + it('ignores directories without both manifest and deploy files', async () => { + await fs.writeFile('.npcubes', ''); + await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] }); + + await fs.mkdirp('only-manifest'); + await fs.writeFile( + 'only-manifest/manifest.mjs', + 'export default { id: "only-manifest", name: "test" }' + ); + + await fs.mkdirp('only-deploy'); + await fs.writeFile('only-deploy/deploy.py', '# deploy'); + + const { cubes, errors } = await loadCubes(); + + expect(Object.keys(cubes)).not.toContain('only-manifest'); + expect(Object.keys(cubes)).not.toContain('only-deploy'); + }); + + it('loads manifest schema and getDefaults', async () => { + await fs.writeFile('.npcubes', ''); + await fs.writeJson('.nopyrc.json', { cubeDirs: ['./'] }); + + await fs.mkdirp('test-schema'); + await fs.writeFile( + 'test-schema/manifest.mjs', + ` + import { z } from 'zod'; + export default { + id: 'test-schema', + name: 'Test Schema Cube', + schema: z.object({ + PORT: z.string().default('3000'), + HOST: z.string().default('localhost'), + }) + } + ` + ); + await fs.writeFile('test-schema/deploy.py', '# deploy'); + + const { cubes, errors } = await loadCubes(); + + expect(errors).toHaveLength(0); + expect(cubes['test-schema']).toBeDefined(); + expect(cubes['test-schema'].getDefaults()).toEqual({ + PORT: '3000', + HOST: 'localhost', + }); + }); +}); diff --git a/packages/nopy/tests/cubes.utils.test.ts b/packages/nopy/tests/cubes.utils.test.ts new file mode 100644 index 0000000..bbe77bd --- /dev/null +++ b/packages/nopy/tests/cubes.utils.test.ts @@ -0,0 +1,44 @@ +/** + * Tests for cubes/utils module + */ + +import { describe, expect, it } from 'vitest'; +import { uniqid } from '../src/cubes/utils.js'; + +describe('uniqid', () => { + it('generates string of default length (5)', () => { + const id = uniqid(); + expect(id).toHaveLength(5); + }); + + it('generates string of specified length', () => { + expect(uniqid(10)).toHaveLength(10); + expect(uniqid(3)).toHaveLength(3); + expect(uniqid(20)).toHaveLength(20); + }); + + it('generates alphanumeric characters only', () => { + const id = uniqid(100); + expect(id).toMatch(/^[A-Za-z0-9]+$/); + }); + + it('generates different values on subsequent calls', () => { + const ids = new Set(); + for (let i = 0; i < 100; i++) { + ids.add(uniqid(10)); + } + // Should have many unique values (some collisions possible but unlikely) + expect(ids.size).toBeGreaterThan(90); + }); + + it('handles edge case of length 1', () => { + const id = uniqid(1); + expect(id).toHaveLength(1); + expect(id).toMatch(/^[A-Za-z0-9]$/); + }); + + it('handles edge case of length 0', () => { + const id = uniqid(0); + expect(id).toBe(''); + }); +}); diff --git a/packages/nopy/tests/executor.test.ts b/packages/nopy/tests/executor.test.ts new file mode 100644 index 0000000..597cd83 --- /dev/null +++ b/packages/nopy/tests/executor.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for nopy.executor module + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type DeployCall, + type ExecutionResult, + outputExecutionPlan, + summarizeResults, +} from '../src/nopy.executor.js'; + +/** + * Helper to create a test deploy call + */ +function createTestCall(cube: string, host: string, deps: string[] = []): DeployCall { + return { + cube, + host, + cwd: `/test/${cube}`, + command: ['pyinfra', host, '-y', `${cube}.deploy.py`], + env: { VAR: 'value' }, + dependencies: deps, + }; +} + +/** + * Helper to create a test execution result + */ +function createTestResult( + cube: string, + host: string, + success: boolean, + duration = 1000 +): ExecutionResult { + return { + cube, + host, + success, + duration, + ...(success ? {} : { error: new Error('Test error') }), + }; +} + +describe('summarizeResults', () => { + it('summarizes successful results', () => { + const results: ExecutionResult[] = [ + createTestResult('cube-a', 'host1', true, 1000), + createTestResult('cube-b', 'host1', true, 2000), + ]; + + const summary = summarizeResults(results); + + expect(summary.total).toBe(2); + expect(summary.successful).toBe(2); + expect(summary.failed).toBe(0); + expect(summary.totalDuration).toBe(3000); + expect(summary.failures).toHaveLength(0); + }); + + it('summarizes failed results', () => { + const results: ExecutionResult[] = [ + createTestResult('cube-a', 'host1', true, 1000), + createTestResult('cube-b', 'host1', false, 500), + ]; + + const summary = summarizeResults(results); + + expect(summary.total).toBe(2); + expect(summary.successful).toBe(1); + expect(summary.failed).toBe(1); + expect(summary.totalDuration).toBe(1500); + expect(summary.failures).toHaveLength(1); + expect(summary.failures[0].cube).toBe('cube-b'); + }); + + it('handles empty results', () => { + const summary = summarizeResults([]); + + expect(summary.total).toBe(0); + expect(summary.successful).toBe(0); + expect(summary.failed).toBe(0); + expect(summary.totalDuration).toBe(0); + }); + + it('handles all failed results', () => { + const results: ExecutionResult[] = [ + createTestResult('cube-a', 'host1', false, 100), + createTestResult('cube-b', 'host1', false, 200), + ]; + + const summary = summarizeResults(results); + + expect(summary.successful).toBe(0); + expect(summary.failed).toBe(2); + expect(summary.failures).toHaveLength(2); + }); +}); + +describe('outputExecutionPlan', () => { + let consoleLogSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('outputs text format by default', () => { + const calls = [createTestCall('cube-a', 'host1')]; + + outputExecutionPlan(calls); + + expect(consoleLogSpy).toHaveBeenCalled(); + const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Execution Plan'); + expect(output).toContain('cube-a'); + expect(output).toContain('host1'); + }); + + it('outputs JSON format when requested', () => { + const calls = [createTestCall('cube-a', 'host1')]; + + outputExecutionPlan(calls, true); + + expect(consoleLogSpy).toHaveBeenCalledTimes(1); + const output = consoleLogSpy.mock.calls[0][0]; + const parsed = JSON.parse(output); + + expect(parsed.plan).toHaveLength(1); + expect(parsed.plan[0].cube).toBe('cube-a'); + expect(parsed.plan[0].host).toBe('host1'); + }); + + it('masks password variables in text output', () => { + const call: DeployCall = { + ...createTestCall('cube-a', 'host1'), + env: { PASSWORD: 'secret', OTHER: 'visible' }, + }; + + outputExecutionPlan([call]); + + const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('********'); + expect(output).not.toContain('secret'); + expect(output).toContain('visible'); + }); + + it('shows step numbers', () => { + const calls = [createTestCall('cube-a', 'host1'), createTestCall('cube-b', 'host1')]; + + outputExecutionPlan(calls); + + const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Step 1'); + expect(output).toContain('Step 2'); + }); + + it('shows total count', () => { + const calls = [ + createTestCall('cube-a', 'host1'), + createTestCall('cube-b', 'host1'), + createTestCall('cube-c', 'host1'), + ]; + + outputExecutionPlan(calls); + + const output = consoleLogSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Total: 3'); + }); +}); diff --git a/packages/nopy/tests/history.test.ts b/packages/nopy/tests/history.test.ts new file mode 100644 index 0000000..cf9a810 --- /dev/null +++ b/packages/nopy/tests/history.test.ts @@ -0,0 +1,255 @@ +/** + * Tests for nopy.history module + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + HISTORY_FILE, + type HistoryEntry, + type SessionHistory, + addToHistory, + clearHistory, + formatHistoryList, + getLastSession, + getSessionById, + listHistory, + loadHistory, + removeFromHistory, + saveHistory, +} from '../src/nopy.history.js'; +import type { NopySession } from '../src/nopy.session.js'; + +describe('Session History', () => { + let originalCwd: string; + let tempDir: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-history-test-')); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + const createTestSession = (cubes: string[] = ['test-cube']): NopySession => ({ + cubes: cubes.map((key) => ({ key, variables: {} })), + hosts: ['@docker/test'], + auth: { method: 'ssh-key' }, + }); + + describe('loadHistory', () => { + it('returns empty history when file does not exist', () => { + const history = loadHistory(); + expect(history.entries).toEqual([]); + }); + + it('loads existing history file', () => { + const testHistory: SessionHistory = { + entries: [ + { + id: 'test-id', + name: 'Test Session', + timestamp: new Date().toISOString(), + session: createTestSession(), + }, + ], + }; + fs.writeFileSync(HISTORY_FILE, JSON.stringify(testHistory)); + + const history = loadHistory(); + expect(history.entries).toHaveLength(1); + expect(history.entries[0].id).toBe('test-id'); + }); + + it('returns empty history on invalid JSON', () => { + fs.writeFileSync(HISTORY_FILE, 'invalid json'); + const history = loadHistory(); + expect(history.entries).toEqual([]); + }); + }); + + describe('saveHistory', () => { + it('creates history file', () => { + const history: SessionHistory = { + entries: [ + { + id: 'test-id', + name: 'Test', + timestamp: new Date().toISOString(), + session: createTestSession(), + }, + ], + }; + + saveHistory(history); + + expect(fs.existsSync(HISTORY_FILE)).toBe(true); + const content = JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8')); + expect(content.entries).toHaveLength(1); + }); + }); + + describe('addToHistory', () => { + it('adds session to empty history', () => { + const session = createTestSession(['apt:essentials']); + const entry = addToHistory(session); + + expect(entry.id).toBeDefined(); + expect(entry.session).toBe(session); + + const history = loadHistory(); + expect(history.entries).toHaveLength(1); + }); + + it('adds newest entries first', () => { + addToHistory(createTestSession(['first'])); + addToHistory(createTestSession(['second'])); + + const history = loadHistory(); + expect(history.entries[0].session.cubes[0].key).toBe('second'); + expect(history.entries[1].session.cubes[0].key).toBe('first'); + }); + + it('respects max entries limit', () => { + for (let i = 0; i < 5; i++) { + addToHistory(createTestSession([`cube-${i}`]), 3); + } + + const history = loadHistory(); + expect(history.entries).toHaveLength(3); + // Should have newest entries + expect(history.entries[0].session.cubes[0].key).toBe('cube-4'); + expect(history.entries[1].session.cubes[0].key).toBe('cube-3'); + expect(history.entries[2].session.cubes[0].key).toBe('cube-2'); + }); + + it('generates descriptive name', () => { + const session = createTestSession(['apt:essentials', 'runtime:docker']); + const entry = addToHistory(session); + + expect(entry.name).toContain('apt:essentials'); + expect(entry.name).toContain('runtime:docker'); + expect(entry.name).toContain('@docker/test'); + }); + }); + + describe('getLastSession', () => { + it('returns undefined for empty history', () => { + expect(getLastSession()).toBeUndefined(); + }); + + it('returns most recent session', () => { + addToHistory(createTestSession(['first'])); + addToHistory(createTestSession(['second'])); + + const last = getLastSession(); + expect(last?.session.cubes[0].key).toBe('second'); + }); + }); + + describe('getSessionById', () => { + it('returns undefined for non-existent ID', () => { + expect(getSessionById('non-existent')).toBeUndefined(); + }); + + it('finds session by ID', () => { + const entry = addToHistory(createTestSession(['test'])); + + const found = getSessionById(entry.id); + expect(found?.id).toBe(entry.id); + }); + }); + + describe('listHistory', () => { + it('returns empty array for no history', () => { + expect(listHistory()).toEqual([]); + }); + + it('returns all entries', () => { + addToHistory(createTestSession(['a'])); + addToHistory(createTestSession(['b'])); + addToHistory(createTestSession(['c'])); + + expect(listHistory()).toHaveLength(3); + }); + }); + + describe('clearHistory', () => { + it('removes all entries', () => { + addToHistory(createTestSession(['a'])); + addToHistory(createTestSession(['b'])); + + clearHistory(); + + expect(listHistory()).toEqual([]); + }); + }); + + describe('removeFromHistory', () => { + it('returns false for non-existent ID', () => { + expect(removeFromHistory('non-existent')).toBe(false); + }); + + it('removes specific entry', () => { + const entry1 = addToHistory(createTestSession(['a'])); + const entry2 = addToHistory(createTestSession(['b'])); + + const removed = removeFromHistory(entry1.id); + + expect(removed).toBe(true); + expect(listHistory()).toHaveLength(1); + expect(getSessionById(entry1.id)).toBeUndefined(); + expect(getSessionById(entry2.id)).toBeDefined(); + }); + }); + + describe('formatHistoryList', () => { + it('shows message for empty history', () => { + const output = formatHistoryList([]); + expect(output).toContain('No sessions'); + }); + + it('formats entries with numbers and IDs', () => { + const entries: HistoryEntry[] = [ + { + id: 'abc123', + name: 'Test Session', + timestamp: new Date().toISOString(), + session: createTestSession(), + }, + ]; + + const output = formatHistoryList(entries); + expect(output).toContain('[1]'); + expect(output).toContain('Test Session'); + expect(output).toContain('abc123'); + expect(output).toContain('Total: 1'); + }); + + it('marks first entry with arrow', () => { + const entries: HistoryEntry[] = [ + { + id: 'first', + name: 'First', + timestamp: new Date().toISOString(), + session: createTestSession(), + }, + { + id: 'second', + name: 'Second', + timestamp: new Date().toISOString(), + session: createTestSession(), + }, + ]; + + const output = formatHistoryList(entries); + expect(output).toContain('→ [1]'); + }); + }); +}); diff --git a/packages/nopy/tests/hooks.test.ts b/packages/nopy/tests/hooks.test.ts new file mode 100644 index 0000000..4cfdde3 --- /dev/null +++ b/packages/nopy/tests/hooks.test.ts @@ -0,0 +1,69 @@ +/** + * Tests for cube hooks using BuildContext + */ + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { BuildContext } from '../src/cubes/dependencies.js'; +import { Cube, Manifest } from '../src/cubes/types.js'; +import { Variables } from '../src/nopy.common.js'; + +describe('Cube Hooks', () => { + const createMockCube = (id: string, name: string, manifest: Partial = {}): Cube => { + const m = Manifest.create({ + id, + name, + schema: z.object({}), + ...manifest, + }); + return new Cube(m, `/tmp/${id}`, 'deploy.py'); + }; + + it('should execute hooks in the correct order', async () => { + const order: string[] = []; + + const cubes: Record = { + main: createMockCube('main', 'Main Cube', { + before: [async ({ exec }) => { + order.push('main:before'); + await exec('before-hook', {}); + }], + after: [async ({ exec }) => { + order.push('main:after'); + await exec('after-hook', {}); + }], + dependencies: () => ['dep'], + }), + 'before-hook': createMockCube('before-hook', 'Before Hook'), + 'after-hook': createMockCube('after-hook', 'After Hook'), + 'dep': createMockCube('dep', 'Dependency'), + }; + + // Note: buildDeployCall also records the main cube execution + // We can't easily spy on buildDeployCall, but we can see the resulting deployCalls order + + const vars = new Variables(); + const context = new BuildContext( + cubes, + vars, + { hosts: ['host1'], cubes: [] } as any, + { env: {} } as any, + { method: 'ssh' } + ); + + await context.resolveCube('main', 'host1'); + + const callOrder = context.deployCalls.map(c => c.cube); + + // Expected order: + // 1. main:before (hook runs) + // 2. before-hook (resolved via exec in before hook) + // 3. dep (dependency of main) + // 4. main (the cube itself) + // 5. main:after (hook runs) + // 6. after-hook (resolved via exec in after hook) + + expect(order).toEqual(['main:before', 'main:after']); + expect(callOrder).toEqual(['before-hook', 'dep', 'main', 'after-hook']); + }); +}); diff --git a/packages/nopy/tests/session.test.ts b/packages/nopy/tests/session.test.ts new file mode 100644 index 0000000..4f2821c --- /dev/null +++ b/packages/nopy/tests/session.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for nopy.session module + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + type NopySession, + createSession, + filterInternalVariables, + listSessions, + loadSession, + saveSession, + separateEnvAndCubeVariables, +} from '../src/nopy.session.js'; + +describe('createSession', () => { + it('creates session with required fields', () => { + const session = createSession({ + cubes: [{ key: 'test', variables: {} }], + hosts: ['localhost'], + auth: { method: 'ssh' }, + }); + + expect(session.cubes).toHaveLength(1); + expect(session.hosts).toEqual(['localhost']); + expect(session.auth.method).toBe('ssh'); + }); + + it('includes optional name', () => { + const session = createSession({ + name: 'My Session', + cubes: [], + hosts: ['localhost'], + auth: { method: 'ssh-key' }, + }); + + expect(session.name).toBe('My Session'); + }); + + it('includes optional env', () => { + const session = createSession({ + cubes: [], + hosts: ['localhost'], + auth: { method: 'ssh-key' }, + env: { KEY: 'value' }, + }); + + expect(session.env).toEqual({ KEY: 'value' }); + }); +}); + +describe('saveSession and loadSession', () => { + let tempDir: string; + let sessionPath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-test-')); + sessionPath = path.join(tempDir, 'test.session.json'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('saves and loads session', async () => { + const session: NopySession = { + cubes: [{ key: 'apt:essentials', variables: { UPDATE: true } }], + hosts: ['@docker/test'], + auth: { method: 'ssh-key' }, + }; + + saveSession(session, sessionPath); + const loaded = await loadSession(sessionPath); + + expect(loaded.cubes).toEqual(session.cubes); + expect(loaded.hosts).toEqual(session.hosts); + expect(loaded.auth).toEqual(session.auth); + }); + + it('creates directory if not exists', () => { + const nestedPath = path.join(tempDir, 'nested', 'dir', 'session.json'); + const session: NopySession = { + cubes: [], + hosts: ['localhost'], + auth: { method: 'ssh' }, + }; + + saveSession(session, nestedPath); + + expect(fs.existsSync(nestedPath)).toBe(true); + }); + + it('throws on missing file', async () => { + await expect(loadSession('/nonexistent/path.json')).rejects.toThrow('Session file not found'); + }); + + it('throws on invalid extension', async () => { + const invalidPath = path.join(tempDir, 'test.txt'); + fs.writeFileSync(invalidPath, '{}'); + + await expect(loadSession(invalidPath)).rejects.toThrow('Unsupported session file format'); + }); + + it('validates required cubes field', async () => { + fs.writeFileSync( + sessionPath, + JSON.stringify({ hosts: ['localhost'], auth: { method: 'ssh' } }) + ); + + await expect(loadSession(sessionPath)).rejects.toThrow('cubes'); + }); + + it('validates required auth field', async () => { + fs.writeFileSync(sessionPath, JSON.stringify({ cubes: [], hosts: ['localhost'] })); + + await expect(loadSession(sessionPath)).rejects.toThrow('auth'); + }); +}); + +describe('listSessions', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nopy-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns empty array for nonexistent directory', () => { + const result = listSessions('/nonexistent/dir'); + expect(result).toEqual([]); + }); + + it('finds .session.json files', () => { + fs.writeFileSync(path.join(tempDir, 'test.session.json'), '{}'); + fs.writeFileSync(path.join(tempDir, 'other.session.json'), '{}'); + fs.writeFileSync(path.join(tempDir, 'not-a-session.json'), '{}'); + + const result = listSessions(tempDir); + + expect(result).toHaveLength(2); + expect(result.some((p) => p.endsWith('test.session.json'))).toBe(true); + expect(result.some((p) => p.endsWith('other.session.json'))).toBe(true); + }); + + it('finds .session.mjs files', () => { + fs.writeFileSync(path.join(tempDir, 'test.session.mjs'), 'export default {}'); + + const result = listSessions(tempDir); + + expect(result).toHaveLength(1); + expect(result[0].endsWith('test.session.mjs')).toBe(true); + }); +}); + +describe('filterInternalVariables', () => { + it('removes customize key', () => { + const input = { customize: true, VAR_A: 'a', VAR_B: 'b' }; + const result = filterInternalVariables(input); + + expect(result).toEqual({ VAR_A: 'a', VAR_B: 'b' }); + expect('customize' in result).toBe(false); + }); + + it('returns empty object for internal-only input', () => { + const result = filterInternalVariables({ customize: true }); + expect(result).toEqual({}); + }); + + it('preserves all non-internal keys', () => { + const input = { A: 1, B: 'two', C: true }; + const result = filterInternalVariables(input); + expect(result).toEqual(input); + }); +}); + +describe('separateEnvAndCubeVariables', () => { + it('separates env variables from cube variables', () => { + const allVars = { ENV_VAR: 'env', CUBE_VAR: 'cube' }; + const envVars = { ENV_VAR: 'original' }; + + const result = separateEnvAndCubeVariables(allVars, envVars); + + expect(result.env).toEqual({ ENV_VAR: 'env' }); + expect(result.cubeVars).toEqual({ CUBE_VAR: 'cube' }); + }); + + it('handles all env variables', () => { + const allVars = { A: 1, B: 2 }; + const envVars = { A: 0, B: 0 }; + + const result = separateEnvAndCubeVariables(allVars, envVars); + + expect(result.env).toEqual({ A: 1, B: 2 }); + expect(result.cubeVars).toEqual({}); + }); + + it('handles all cube variables', () => { + const allVars = { A: 1, B: 2 }; + const envVars = {}; + + const result = separateEnvAndCubeVariables(allVars, envVars); + + expect(result.env).toEqual({}); + expect(result.cubeVars).toEqual({ A: 1, B: 2 }); + }); +}); diff --git a/packages/nopy/tsconfig.json b/packages/nopy/tsconfig.json new file mode 100644 index 0000000..c0f22cd --- /dev/null +++ b/packages/nopy/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "outDir": "dist", + "rootDir": "src", + "lib": ["ES2020"], + "composite": true, + "module": "NodeNext", + "types": ["jest", "node"] + }, + "include": ["src/**/*"], + "exclude": ["coverage", "node_modules", "dist"], + "references": [] +} diff --git a/packages/nopy/vitest.config.ts b/packages/nopy/vitest.config.ts new file mode 100644 index 0000000..a979ad5 --- /dev/null +++ b/packages/nopy/vitest.config.ts @@ -0,0 +1,16 @@ +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', 'html'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/nopy.cli.ts'], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..64de258 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2352 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +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 + +importers: + + .: + dependencies: + commander: + specifier: ^13.1.0 + version: 13.1.0 + devDependencies: + '@biomejs/biome': + specifier: ^1.9.4 + version: 1.9.4 + '@logtape/logtape': + specifier: 0.8.0 + version: 0.8.0 + '@types/jest': + specifier: 29.5.5 + version: 29.5.5 + '@types/node': + specifier: '>=21' + version: 22.10.7 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260303.1 + version: 7.0.0-dev.20260303.1 + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) + typescript: + specifier: 5.7.3 + version: 5.7.3 + + packages/keyman: + dependencies: + execa: + specifier: 9.5.2 + version: 9.5.2 + inquirer: + specifier: 8.2.4 + version: 8.2.4 + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) + typed-dotenv: + specifier: 10.0.2 + version: 10.0.2 + typescript: + specifier: 5.7.3 + version: 5.7.3 + zod: + specifier: ^3.24.1 + version: 3.24.1 + zx: + specifier: ^8.3.0 + version: 8.3.0 + + packages/nopy: + dependencies: + '@logtape/logtape': + specifier: 0.8.0 + version: 0.8.0 + commander: + specifier: ^13.1.0 + version: 13.1.0 + enquirer: + specifier: ^2.4.1 + version: 2.4.1 + execa: + specifier: 9.5.2 + version: 9.5.2 + fuzzy: + specifier: ^0.1.3 + version: 0.1.3 + inquirer: + specifier: 8.2.4 + version: 8.2.4 + inquirer-checkbox-plus-prompt: + specifier: ^1.0.1 + version: 1.4.2(inquirer@8.2.4) + ts-node: + specifier: 10.9.2 + version: 10.9.2(@types/node@22.10.7)(typescript@5.7.3) + typescript: + specifier: 5.7.3 + version: 5.7.3 + yaml: + specifier: ^2.8.2 + version: 2.8.2 + zod: + specifier: ^3.24.1 + version: 3.24.1 + zx: + specifier: ^8.3.0 + version: 8.3.0 + devDependencies: + '@types/inquirer': + specifier: ^8.2.10 + version: 8.2.12 + '@types/node': + specifier: '>=21' + version: 22.10.7 + '@types/uniqid': + specifier: ^5.3.4 + version: 5.3.4 + vitest: + specifier: ^1.6.0 + version: 1.6.1(@types/node@22.10.7) + +packages: + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@1.9.4': + resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@1.9.4': + resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@1.9.4': + resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@1.9.4': + resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@1.9.4': + resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@1.9.4': + resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@1.9.4': + resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@1.9.4': + resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@1.9.4': + resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@logtape/logtape@0.8.0': + resolution: {integrity: sha512-kTGbRE4tbHGlZk3+k9fIJbrLODcDiAvv9X2q+tHXjWyNjWXEH46efDBjsE63zDzR/b6Q4Abiloa4VNwKSDLyzQ==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/fs-extra@11.0.4': + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + + '@types/inquirer@8.2.12': + resolution: {integrity: sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.5': + resolution: {integrity: sha512-ebylz2hnsWR9mYvmBFbXJXr+33UPc4+ZdxyDXh5w0FlPBTfCVN3wPL+kuOiQt3xvrK419v7XWeAs+AeOksafXg==} + + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + + '@types/node@22.10.7': + resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/through@0.0.33': + resolution: {integrity: sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==} + + '@types/uniqid@5.3.4': + resolution: {integrity: sha512-AgC+o3/8/QEHuU3w5w2jZ8auQtjSJ/s8G8RfEk9CYLogK1RGXqxhHH0wOEAu8uHXjvj8oh/dRtfgok4IHKxh/Q==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-jIIQWmFi0bJY4ML8/7eyz1EGpkI6E0R1E5l4lxJdV/orpMr91vYfAajKICs7DUiMGEJX9HpeiA6TD2piw4DKPQ==} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-UkaK+J3f185VXBiAGNG4UKHjGzn4R/nhAz5tArnCKHnIUI7rEnsIm4Xlo5YwmgIATFMU1sVwWUwRshVkMVeFAw==} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-ELJSV2Q4/mK+ampttssOl4H9s9ZBCc3k7y/u5ivJX8TdlMvZuH/JHqI6cS4Y00flt0R5wc70X+Nlcor4I4+rpw==} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-H84jTRYqUfc/vhuVGQ6VKcBvJoZ4YmomWDx9U4uwYgW6eoUcRpDXqv3S3YqcNJcUmz22d/tTwIYz8ssXNLa/Qw==} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-HkddFrPJ0jcrohe+HnCqVTv8PunjqNs7FisRmtIAnc36+ccraDB6MVFEdPyAIL3PUID+TP/ESquqeKNnB7HdrQ==} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-kTTMrBpWuxbHPt9hAFQSWeP//5Oa0KOdAEvceOfXUJhTS8RAA/kZSlFGE/Zw1EtrFLQx2J7uTHUZnYxH1hYXNw==} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-UcVZbf4pra46Yx/eFV6m9F+awvihliPEud4Rq+A8Q3q3zI67VRaNH6R2/qeo4AqqKRahmiEdLM6Tnm+gPtLRQQ==} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260303.1': + resolution: {integrity: sha512-BDHJjXlPldInEogbzAc7OCLvT75p3rdkmb5YIA6Je0vjg+5z1UQp3moAvcBGvZQflO/gusOd9a74EfrMVUU/4g==} + hasBin: true + + '@vitest/expect@1.6.1': + resolution: {integrity: sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==} + + '@vitest/runner@1.6.1': + resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} + + '@vitest/snapshot@1.6.1': + resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} + + '@vitest/spy@1.6.1': + resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} + + '@vitest/utils@1.6.1': + resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + dotenv@10.0.0: + resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} + engines: {node: '>=10'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + execa@9.5.2: + resolution: {integrity: sha512-EHlpxMCpHWSAh1dgS6bVeoLAXGnJNdR93aabr4QCGbzOM73o5XmRfM/e5FUqsw3aagP8S8XEWUWFAxnRBnAF0Q==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fuzzy@0.1.3: + resolution: {integrity: sha512-/gZffu4ykarLrCiP3Ygsa86UAo1E5vEVlvTrpkKywXSbP9Xhln3oSp9QSV57gEq3JFFpGJ4GZ+5zdEp3FcUh4w==} + engines: {node: '>= 0.6.0'} + + get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + human-signals@8.0.0: + resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer-checkbox-plus-prompt@1.4.2: + resolution: {integrity: sha512-W8/NL9x5A81Oq9ZfbYW5c1LuwtAhc/oB/u9YZZejna0pqrajj27XhnUHygJV0Vn5TvcDy1VJcD2Ld9kTk40dvg==} + peerDependencies: + inquirer: < 9.x + + inquirer@8.2.4: + resolution: {integrity: sha512-nn4F01dxU8VeKfq192IjLsxu0/OmMZ4Lg3xKAns148rCaXP6ntAoEkVYZThWjwON8AlzdZZi6oqnhNbxUG9hVg==} + engines: {node: '>=12.0.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + pretty-ms@9.2.0: + resolution: {integrity: sha512-4yf0QO/sllf/1zbZWYnvWw3NxCQwLXKzIj0G849LSufP15BXKM0rbD2Z3wVnkMfjdn/CB0Dpp444gYAACdsplg==} + engines: {node: '>=18'} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + rxjs@6.6.7: + resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} + engines: {npm: '>=2.0.0'} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + + tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '>=21' + typescript: 5.7.3 + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typed-dotenv@10.0.2: + resolution: {integrity: sha512-gQKZ0vTzyQ4fqQbDPY4dI9l5BR5+oPSRHBsyh1L7n1K9RK81PwOV8WTqR29o8apxU/C83OttEj3Lu0Nw/WULxw==} + engines: {node: '>=10.0.0'} + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': '>=21' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@1.6.1: + resolution: {integrity: sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': '>=21' + '@vitest/browser': 1.6.1 + '@vitest/ui': 1.6.1 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yoctocolors@2.1.1: + resolution: {integrity: sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==} + engines: {node: '>=18'} + + zod@3.24.1: + resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + + zx@8.3.0: + resolution: {integrity: sha512-L8mY3yfJwo3a8ZDD6f9jZzAcRWJZYcV8GauZmBxLB/aSTwaMzMIEVpPp2Kyx+7yF0gdvuxKnMxAZRft9UCawiw==} + engines: {node: '>= 12.17.0'} + hasBin: true + +snapshots: + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.25.9': {} + + '@biomejs/biome@1.9.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 1.9.4 + '@biomejs/cli-darwin-x64': 1.9.4 + '@biomejs/cli-linux-arm64': 1.9.4 + '@biomejs/cli-linux-arm64-musl': 1.9.4 + '@biomejs/cli-linux-x64': 1.9.4 + '@biomejs/cli-linux-x64-musl': 1.9.4 + '@biomejs/cli-win32-arm64': 1.9.4 + '@biomejs/cli-win32-x64': 1.9.4 + + '@biomejs/cli-darwin-arm64@1.9.4': + optional: true + + '@biomejs/cli-darwin-x64@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-arm64@1.9.4': + optional: true + + '@biomejs/cli-linux-x64-musl@1.9.4': + optional: true + + '@biomejs/cli-linux-x64@1.9.4': + optional: true + + '@biomejs/cli-win32-arm64@1.9.4': + optional: true + + '@biomejs/cli-win32-x64@1.9.4': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.10.7 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@logtape/logtape@0.8.0': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@sinclair/typebox@0.27.8': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/estree@1.0.8': {} + + '@types/fs-extra@11.0.4': + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 22.10.7 + optional: true + + '@types/inquirer@8.2.12': + dependencies: + '@types/through': 0.0.33 + rxjs: 7.8.2 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.5': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 22.10.7 + optional: true + + '@types/node@22.10.7': + dependencies: + undici-types: 6.20.0 + + '@types/stack-utils@2.0.3': {} + + '@types/through@0.0.33': + dependencies: + '@types/node': 22.10.7 + + '@types/uniqid@5.3.4': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260303.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260303.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260303.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260303.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260303.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260303.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260303.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260303.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260303.1 + + '@vitest/expect@1.6.1': + dependencies: + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + chai: 4.5.0 + + '@vitest/runner@1.6.1': + dependencies: + '@vitest/utils': 1.6.1 + p-limit: 5.0.0 + pathe: 1.1.2 + + '@vitest/snapshot@1.6.1': + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + + '@vitest/spy@1.6.1': + dependencies: + tinyspy: 2.2.1 + + '@vitest/utils@1.6.1': + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + arg@4.1.3: {} + + assertion-error@1.1.0: {} + + base64-js@1.5.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + chai@4.5.0: + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chardet@0.7.0: {} + + check-error@1.0.3: + dependencies: + get-func-name: 2.0.2 + + ci-info@3.9.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-width@3.0.0: {} + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@13.1.0: {} + + confbox@0.1.8: {} + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@4.1.4: + dependencies: + type-detect: 4.1.0 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + diff-sequences@29.6.3: {} + + diff@4.0.2: {} + + dotenv@10.0.0: {} + + emoji-regex@8.0.0: {} + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + execa@9.5.2: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.0 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.2.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.1 + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + fuzzy@0.1.3: {} + + get-func-name@2.0.2: {} + + get-stream@8.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + human-signals@5.0.0: {} + + human-signals@8.0.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + inquirer-checkbox-plus-prompt@1.4.2(inquirer@8.2.4): + dependencies: + chalk: 4.1.2 + cli-cursor: 3.1.0 + figures: 3.2.0 + inquirer: 8.2.4 + lodash: 4.17.23 + rxjs: 6.6.7 + + inquirer@8.2.4: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.23 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 7.0.0 + + is-fullwidth-code-point@3.0.0: {} + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-stream@3.0.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@0.1.0: {} + + is-unicode-supported@2.1.0: {} + + isexe@2.0.0: {} + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.26.2 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 22.10.7 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + local-pkg@0.5.1: + dependencies: + mlly: 1.8.0 + pkg-types: 1.3.1 + + lodash@4.17.23: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + loupe@2.3.7: + dependencies: + get-func-name: 2.0.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-error@1.3.6: {} + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mute-stream@0.0.8: {} + + nanoid@3.3.11: {} + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + os-tmpdir@1.0.2: {} + + p-limit@5.0.0: + dependencies: + yocto-queue: 1.2.2 + + parse-ms@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@1.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pretty-ms@9.2.0: + dependencies: + parse-ms: 4.0.0 + + react-is@18.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + run-async@2.4.1: {} + + rxjs@6.6.7: + dependencies: + tslib: 1.14.1 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-final-newline@3.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-literal@2.1.1: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + through@2.3.8: {} + + tinybench@2.9.0: {} + + tinypool@0.8.4: {} + + tinyspy@2.2.1: {} + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-node@10.9.2(@types/node@22.10.7)(typescript@5.7.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 22.10.7 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.7.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + type-detect@4.1.0: {} + + type-fest@0.21.3: {} + + typed-dotenv@10.0.2: + dependencies: + dotenv: 10.0.0 + lodash: 4.17.23 + + typescript@5.7.3: {} + + ufo@1.6.3: {} + + undici-types@6.20.0: {} + + unicorn-magic@0.3.0: {} + + util-deprecate@1.0.2: {} + + v8-compile-cache-lib@3.0.1: {} + + vite-node@1.6.1(@types/node@22.10.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@22.10.7) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.10.7): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 22.10.7 + fsevents: 2.3.3 + + vitest@1.6.1(@types/node@22.10.7): + dependencies: + '@vitest/expect': 1.6.1 + '@vitest/runner': 1.6.1 + '@vitest/snapshot': 1.6.1 + '@vitest/spy': 1.6.1 + '@vitest/utils': 1.6.1 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.4.3 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@22.10.7) + vite-node: 1.6.1(@types/node@22.10.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.10.7 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + yaml@2.8.2: {} + + yn@3.1.1: {} + + yocto-queue@1.2.2: {} + + yoctocolors@2.1.1: {} + + zod@3.24.1: {} + + zx@8.3.0: + optionalDependencies: + '@types/fs-extra': 11.0.4 + '@types/node': 22.10.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..39ac5e6 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - 'packages/*' + - 'projects/*' +onlyBuiltDependencies: + - "@biomejs/biome" + - "esbuild" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..c5def38 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "nodenext", + "lib": ["ES2020"], + "moduleResolution": "nodenext", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "paths": { + "@bitstack/nopy": ["./packages/nopy/src"], + "@bitstack/keyman": ["./packages/keyman/src"] + } + }, + "ts-node": { + "experimentalSpecifierResolution": "node", + "transpileOnly": true, + "esm": true + }, + "exclude": ["coverage", "node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..77ab887 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "files": [], + "references": [ + { "path": "./packages/nopy" }, + { "path": "./packages/keyman" } + ] +}