From d78b198e51a81844c6de7f87406380dc5f7744e6 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Fri, 20 Mar 2026 16:39:58 +0800 Subject: [PATCH 1/3] feat: relaunch runseal beta foundation --- .github/protection.yml | 10 + .github/release-notes/beta/v0.1.0-beta.0.md | 12 + .github/workflows/ci.yml | 26 - .github/workflows/converge.yml | 62 - .github/workflows/docs.yml | 22 +- .github/workflows/first-star-observer.yml | 173 -- .github/workflows/release-beta.yml | 151 +- .github/workflows/release-readiness.yml | 30 - .github/workflows/release.yml | 219 +- AGENTS.md | 69 +- CONTRIBUTING.md | 24 + Cargo.lock | 44 +- Cargo.toml | 27 +- LICENSE | 21 + README.md | 89 +- README.zh-CN.md | 88 +- SECURITY.md | 23 + TROUBLESHOOTING.md | 20 - app/Cargo.toml | 24 + .../examples/runseal.sample.json | 4 +- src/bin/envlock.rs => app/src/bin/runseal.rs | 56 +- {src => app/src}/commands/alias.rs | 18 +- {src => app/src}/commands/mod.rs | 1 - {src => app/src}/commands/preview.rs | 0 {src => app/src}/commands/profiles.rs | 18 +- {src => app/src}/commands/self_update.rs | 116 +- {src => app/src}/commands/skill.rs | 36 +- {src => app/src}/core/alias_store.rs | 53 +- {src => app/src}/core/app.rs | 0 app/src/core/config.rs | 121 + {src => app/src}/core/env_key.rs | 0 {src => app/src}/core/injections/command.rs | 65 +- {src => app/src}/core/injections/env.rs | 186 +- {src => app/src}/core/injections/mod.rs | 184 +- {src => app/src}/core/injections/symlink.rs | 83 +- {src => app/src}/core/mod.rs | 0 {src => app/src}/core/profile.rs | 97 +- {src => app/src}/core/runtime.rs | 51 +- app/src/helpers/mod.rs | 248 ++ {src => app/src}/lib.rs | 2 +- {src => app/src}/logging.rs | 6 +- {tests => app/tests}/alias.rs | 88 +- {tests => app/tests}/command_mode.rs | 22 +- {tests => app/tests}/default_profile.rs | 42 +- app/tests/helper_node.rs | 630 +++++ app/tests/logging.rs | 155 + {tests => app/tests}/output_mode.rs | 18 +- {tests => app/tests}/preview.rs | 8 +- {tests => app/tests}/profiles.rs | 22 +- app/tests/unit/commands/self_update.rs | 80 + app/tests/unit/commands/skill.rs | 20 + app/tests/unit/core/alias_store.rs | 31 + app/tests/unit/core/config.rs | 137 + app/tests/unit/core/injections/command.rs | 61 + app/tests/unit/core/injections/env.rs | 182 ++ app/tests/unit/core/injections/mod.rs | 180 ++ app/tests/unit/core/injections/symlink.rs | 79 + app/tests/unit/core/profile.rs | 93 + app/tests/unit/core/runtime.rs | 41 + docker-compose.yml | 24 + docker/node-helper.Dockerfile | 17 + docs/.vitepress/config.ts | 168 +- .../theme/components/GithubStars.vue | 6 +- .../.vitepress/theme/components/PostShell.vue | 28 + docs/.vitepress/theme/index.ts | 2 + docs/.vitepress/theme/style.css | 421 ++- docs/changelog.md | 8 - docs/changelog/v0.1.0.md | 15 + docs/changelog/v0.4.5.md | 5 - docs/changelog/v0.4.6-beta.1.md | 6 - docs/changelog/v0.4.6-beta.10.md | 5 - docs/changelog/v0.4.6-beta.2.md | 5 - docs/changelog/v0.4.6-beta.3.md | 4 - docs/changelog/v0.4.6-beta.4.md | 5 - docs/changelog/v0.4.6-beta.5.md | 5 - docs/changelog/v0.4.6-beta.6.md | 6 - docs/changelog/v0.4.6-beta.7.md | 4 - docs/changelog/v0.4.6-beta.8.md | 4 - docs/changelog/v0.4.6-beta.9.md | 5 - docs/changelog/v0.4.6.md | 84 - docs/changelog/v0.4.7-beta.1.md | 5 - docs/changelog/v0.4.7.md | 19 - docs/explanation/design-boundaries.md | 26 - docs/explanation/faq.md | 46 +- docs/explanation/first-star-observability.md | 63 - docs/explanation/geo-index.md | 45 - docs/explanation/language-maintenance.md | 5 - .../{envlock-score => runseal-score}/good.md | 4 +- .../native.md | 2 +- .../normal.md | 6 +- .../{envlock-score => runseal-score}/other.md | 2 +- docs/explanation/support-policy.md | 16 - docs/explanation/troubleshooting.md | 30 - docs/explanation/why-envlock.md | 23 - docs/how-to/ci-integration.md | 48 - docs/how-to/command-mode.md | 28 - docs/how-to/common-recipes.md | 113 - docs/how-to/docs-maintenance.md | 30 - docs/how-to/install.md | 32 +- docs/how-to/migrate-to-v0.2.md | 33 - docs/how-to/migrate-to-v0.3.md | 43 - docs/how-to/release-operator-playbook.md | 61 - docs/how-to/release-validation.md | 56 - docs/how-to/update-and-uninstall.md | 44 - docs/how-to/use-profiles.md | 20 +- docs/index.md | 4 +- docs/install.md | 3 - docs/node/01-making-npm-i-g-pnpm-sealable.md | 64 + .../node/02-what-node-still-needs-to-prove.md | 189 ++ docs/node/03-where-corepack-fits.md | 157 ++ docs/node/04-which-boundaries-are-not-ours.md | 93 + docs/node/05-which-node-surface-wins.md | 98 + docs/package.json | 13 + docs/posts/how-we-want-to-build-runseal.md | 97 + docs/posts/what-is-runseal.md | 80 + docs/posts/why-we-want-to-build-runseal.md | 115 + docs/public/changelog-lite.json | 8 +- docs/public/favicon.ico | Bin 458930 -> 12455 bytes docs/public/favicon.png | Bin 458930 -> 307766 bytes docs/public/favicon.svg | 1048 +------ docs/public/hero-shell.svg | 10 +- docs/public/home-layout-wc.js | 56 +- docs/reference/agent-coldstart-checklist.md | 42 - docs/reference/agent-meta-contract.md | 37 - docs/reference/cli.md | 103 - docs/reference/environment.md | 33 - docs/reference/profile.md | 67 - docs/reference/quick-reference.md | 82 - docs/reference/release.md | 43 - docs/release.md | 3 - docs/support-policy.md | 3 - docs/tutorials/first-star-trigger.md | 62 - docs/tutorials/quick-start.md | 88 - docs/uninstall.md | 3 - docs/update.md | 3 - docs/zh-CN/changelog.md | 8 - docs/zh-CN/changelog/v0.1.0.md | 15 + docs/zh-CN/explanation/design-boundaries.md | 26 - .../zh-CN/explanation/envlock-score/normal.md | 12 - docs/zh-CN/explanation/faq.md | 57 +- .../explanation/first-star-observability.md | 59 - docs/zh-CN/explanation/geo-index.md | 45 - .../zh-CN/explanation/language-maintenance.md | 5 - .../{envlock-score => runseal-score}/good.md | 4 +- .../native.md | 2 +- .../zh-CN/explanation/runseal-score/normal.md | 12 + .../{envlock-score => runseal-score}/other.md | 2 +- docs/zh-CN/explanation/support-policy.md | 16 - docs/zh-CN/explanation/troubleshooting.md | 30 - docs/zh-CN/explanation/why-envlock.md | 23 - docs/zh-CN/how-to/ci-integration.md | 48 - docs/zh-CN/how-to/command-mode.md | 28 - docs/zh-CN/how-to/common-recipes.md | 113 - docs/zh-CN/how-to/docs-maintenance.md | 30 - docs/zh-CN/how-to/install.md | 32 +- docs/zh-CN/how-to/migrate-to-v0.2.md | 33 - docs/zh-CN/how-to/migrate-to-v0.3.md | 43 - .../zh-CN/how-to/release-operator-playbook.md | 53 - docs/zh-CN/how-to/release-validation.md | 42 - docs/zh-CN/how-to/update-and-uninstall.md | 44 - docs/zh-CN/how-to/use-profiles.md | 20 +- docs/zh-CN/index.md | 4 +- .../node/01-making-npm-i-g-pnpm-sealable.md | 74 + .../node/02-what-node-still-needs-to-prove.md | 189 ++ docs/zh-CN/node/03-where-corepack-fits.md | 155 + .../node/04-which-boundaries-are-not-ours.md | 93 + docs/zh-CN/node/05-which-node-surface-wins.md | 98 + .../posts/how-we-want-to-build-runseal.md | 101 + docs/zh-CN/posts/what-is-runseal.md | 80 + .../posts/why-we-want-to-build-runseal.md | 115 + .../reference/agent-coldstart-checklist.md | 42 - docs/zh-CN/reference/agent-meta-contract.md | 37 - docs/zh-CN/reference/cli.md | 103 - docs/zh-CN/reference/environment.md | 33 - docs/zh-CN/reference/profile.md | 67 - docs/zh-CN/reference/quick-reference.md | 82 - docs/zh-CN/reference/release.md | 43 - docs/zh-CN/tutorials/first-star-trigger.md | 62 - docs/zh-CN/tutorials/quick-start.md | 88 - helpers/node.sh | 738 +++++ package-lock.json | 2511 ----------------- package.json | 14 +- pnpm-lock.yaml | 12 + pnpm-workspace.yaml | 2 + scripts/converge-check.sh | 23 - scripts/dev/docs.sh | 108 + .../agent-meta.sh} | 6 +- .../agent-routes.sh} | 6 +- scripts/docs/alignment.sh | 51 + .../{verify-doc-links.sh => docs/links.sh} | 2 +- .../public-surface.sh} | 6 +- scripts/e2e-smoke.sh | 105 - scripts/e2e/smoke.sh | 105 + scripts/{ => manage}/install.sh | 30 +- scripts/{ => manage}/uninstall.sh | 8 +- scripts/observe_first_star.py | 296 -- scripts/plugins/node.sh | 538 ---- scripts/release-ready.sh | 58 - scripts/release/accept.sh | 58 + scripts/release/checksum.sh | 15 + scripts/release/collate.sh | 13 + scripts/release/common.sh | 48 + scripts/release/package.sh | 25 + .../{build-skill-zip.sh => release/skill.sh} | 5 +- .../{release-smoke.sh => release/smoke.sh} | 6 +- scripts/release/verify.sh | 14 + scripts/verify-doc-alignment.sh | 57 - scripts/version-sync-check.sh | 94 - skills/envlock/SKILL.md | 14 - skills/runseal/SKILL.md | 14 + src/commands/plugin.rs | 20 - src/core/config.rs | 258 -- src/plugins/host.rs | 158 -- src/plugins/mod.rs | 9 - src/plugins/patch.rs | 149 - tests/logging.rs | 230 -- tests/plugin_node.rs | 971 ------- 217 files changed, 6433 insertions(+), 11211 deletions(-) create mode 100644 .github/protection.yml create mode 100644 .github/release-notes/beta/v0.1.0-beta.0.md delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/converge.yml delete mode 100644 .github/workflows/first-star-observer.yml delete mode 100644 .github/workflows/release-readiness.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md delete mode 100644 TROUBLESHOOTING.md create mode 100644 app/Cargo.toml rename examples/envlock.sample.json => app/examples/runseal.sample.json (85%) rename src/bin/envlock.rs => app/src/bin/runseal.rs (89%) rename {src => app/src}/commands/alias.rs (68%) rename {src => app/src}/commands/mod.rs (84%) rename {src => app/src}/commands/preview.rs (100%) rename {src => app/src}/commands/profiles.rs (87%) rename {src => app/src}/commands/self_update.rs (75%) rename {src => app/src}/commands/skill.rs (89%) rename {src => app/src}/core/alias_store.rs (59%) rename {src => app/src}/core/app.rs (100%) create mode 100644 app/src/core/config.rs rename {src => app/src}/core/env_key.rs (100%) rename {src => app/src}/core/injections/command.rs (73%) rename {src => app/src}/core/injections/env.rs (55%) rename {src => app/src}/core/injections/mod.rs (50%) rename {src => app/src}/core/injections/symlink.rs (50%) rename {src => app/src}/core/mod.rs (100%) rename {src => app/src}/core/profile.rs (54%) rename {src => app/src}/core/runtime.rs (69%) create mode 100644 app/src/helpers/mod.rs rename {src => app/src}/lib.rs (89%) rename {src => app/src}/logging.rs (94%) rename {tests => app/tests}/alias.rs (55%) rename {tests => app/tests}/command_mode.rs (86%) rename {tests => app/tests}/default_profile.rs (61%) create mode 100644 app/tests/helper_node.rs create mode 100644 app/tests/logging.rs rename {tests => app/tests}/output_mode.rs (80%) rename {tests => app/tests}/preview.rs (93%) rename {tests => app/tests}/profiles.rs (60%) create mode 100644 app/tests/unit/commands/self_update.rs create mode 100644 app/tests/unit/commands/skill.rs create mode 100644 app/tests/unit/core/alias_store.rs create mode 100644 app/tests/unit/core/config.rs create mode 100644 app/tests/unit/core/injections/command.rs create mode 100644 app/tests/unit/core/injections/env.rs create mode 100644 app/tests/unit/core/injections/mod.rs create mode 100644 app/tests/unit/core/injections/symlink.rs create mode 100644 app/tests/unit/core/profile.rs create mode 100644 app/tests/unit/core/runtime.rs create mode 100644 docker-compose.yml create mode 100644 docker/node-helper.Dockerfile create mode 100644 docs/.vitepress/theme/components/PostShell.vue delete mode 100644 docs/changelog.md create mode 100644 docs/changelog/v0.1.0.md delete mode 100644 docs/changelog/v0.4.5.md delete mode 100644 docs/changelog/v0.4.6-beta.1.md delete mode 100644 docs/changelog/v0.4.6-beta.10.md delete mode 100644 docs/changelog/v0.4.6-beta.2.md delete mode 100644 docs/changelog/v0.4.6-beta.3.md delete mode 100644 docs/changelog/v0.4.6-beta.4.md delete mode 100644 docs/changelog/v0.4.6-beta.5.md delete mode 100644 docs/changelog/v0.4.6-beta.6.md delete mode 100644 docs/changelog/v0.4.6-beta.7.md delete mode 100644 docs/changelog/v0.4.6-beta.8.md delete mode 100644 docs/changelog/v0.4.6-beta.9.md delete mode 100644 docs/changelog/v0.4.6.md delete mode 100644 docs/changelog/v0.4.7-beta.1.md delete mode 100644 docs/changelog/v0.4.7.md delete mode 100644 docs/explanation/design-boundaries.md delete mode 100644 docs/explanation/first-star-observability.md delete mode 100644 docs/explanation/geo-index.md delete mode 100644 docs/explanation/language-maintenance.md rename docs/explanation/{envlock-score => runseal-score}/good.md (79%) rename docs/explanation/{envlock-score => runseal-score}/native.md (95%) rename docs/explanation/{envlock-score => runseal-score}/normal.md (57%) rename docs/explanation/{envlock-score => runseal-score}/other.md (89%) delete mode 100644 docs/explanation/support-policy.md delete mode 100644 docs/explanation/troubleshooting.md delete mode 100644 docs/explanation/why-envlock.md delete mode 100644 docs/how-to/ci-integration.md delete mode 100644 docs/how-to/command-mode.md delete mode 100644 docs/how-to/common-recipes.md delete mode 100644 docs/how-to/docs-maintenance.md delete mode 100644 docs/how-to/migrate-to-v0.2.md delete mode 100644 docs/how-to/migrate-to-v0.3.md delete mode 100644 docs/how-to/release-operator-playbook.md delete mode 100644 docs/how-to/release-validation.md delete mode 100644 docs/how-to/update-and-uninstall.md delete mode 100644 docs/install.md create mode 100644 docs/node/01-making-npm-i-g-pnpm-sealable.md create mode 100644 docs/node/02-what-node-still-needs-to-prove.md create mode 100644 docs/node/03-where-corepack-fits.md create mode 100644 docs/node/04-which-boundaries-are-not-ours.md create mode 100644 docs/node/05-which-node-surface-wins.md create mode 100644 docs/package.json create mode 100644 docs/posts/how-we-want-to-build-runseal.md create mode 100644 docs/posts/what-is-runseal.md create mode 100644 docs/posts/why-we-want-to-build-runseal.md delete mode 100644 docs/reference/agent-coldstart-checklist.md delete mode 100644 docs/reference/agent-meta-contract.md delete mode 100644 docs/reference/cli.md delete mode 100644 docs/reference/environment.md delete mode 100644 docs/reference/profile.md delete mode 100644 docs/reference/quick-reference.md delete mode 100644 docs/reference/release.md delete mode 100644 docs/release.md delete mode 100644 docs/support-policy.md delete mode 100644 docs/tutorials/first-star-trigger.md delete mode 100644 docs/tutorials/quick-start.md delete mode 100644 docs/uninstall.md delete mode 100644 docs/update.md delete mode 100644 docs/zh-CN/changelog.md create mode 100644 docs/zh-CN/changelog/v0.1.0.md delete mode 100644 docs/zh-CN/explanation/design-boundaries.md delete mode 100644 docs/zh-CN/explanation/envlock-score/normal.md delete mode 100644 docs/zh-CN/explanation/first-star-observability.md delete mode 100644 docs/zh-CN/explanation/geo-index.md delete mode 100644 docs/zh-CN/explanation/language-maintenance.md rename docs/zh-CN/explanation/{envlock-score => runseal-score}/good.md (77%) rename docs/zh-CN/explanation/{envlock-score => runseal-score}/native.md (95%) create mode 100644 docs/zh-CN/explanation/runseal-score/normal.md rename docs/zh-CN/explanation/{envlock-score => runseal-score}/other.md (89%) delete mode 100644 docs/zh-CN/explanation/support-policy.md delete mode 100644 docs/zh-CN/explanation/troubleshooting.md delete mode 100644 docs/zh-CN/explanation/why-envlock.md delete mode 100644 docs/zh-CN/how-to/ci-integration.md delete mode 100644 docs/zh-CN/how-to/command-mode.md delete mode 100644 docs/zh-CN/how-to/common-recipes.md delete mode 100644 docs/zh-CN/how-to/docs-maintenance.md delete mode 100644 docs/zh-CN/how-to/migrate-to-v0.2.md delete mode 100644 docs/zh-CN/how-to/migrate-to-v0.3.md delete mode 100644 docs/zh-CN/how-to/release-operator-playbook.md delete mode 100644 docs/zh-CN/how-to/release-validation.md delete mode 100644 docs/zh-CN/how-to/update-and-uninstall.md create mode 100644 docs/zh-CN/node/01-making-npm-i-g-pnpm-sealable.md create mode 100644 docs/zh-CN/node/02-what-node-still-needs-to-prove.md create mode 100644 docs/zh-CN/node/03-where-corepack-fits.md create mode 100644 docs/zh-CN/node/04-which-boundaries-are-not-ours.md create mode 100644 docs/zh-CN/node/05-which-node-surface-wins.md create mode 100644 docs/zh-CN/posts/how-we-want-to-build-runseal.md create mode 100644 docs/zh-CN/posts/what-is-runseal.md create mode 100644 docs/zh-CN/posts/why-we-want-to-build-runseal.md delete mode 100644 docs/zh-CN/reference/agent-coldstart-checklist.md delete mode 100644 docs/zh-CN/reference/agent-meta-contract.md delete mode 100644 docs/zh-CN/reference/cli.md delete mode 100644 docs/zh-CN/reference/environment.md delete mode 100644 docs/zh-CN/reference/profile.md delete mode 100644 docs/zh-CN/reference/quick-reference.md delete mode 100644 docs/zh-CN/reference/release.md delete mode 100644 docs/zh-CN/tutorials/first-star-trigger.md delete mode 100644 docs/zh-CN/tutorials/quick-start.md create mode 100644 helpers/node.sh delete mode 100644 package-lock.json create mode 100644 pnpm-workspace.yaml delete mode 100755 scripts/converge-check.sh create mode 100755 scripts/dev/docs.sh rename scripts/{verify-agent-meta.sh => docs/agent-meta.sh} (94%) rename scripts/{check-agent-routes.sh => docs/agent-routes.sh} (91%) create mode 100755 scripts/docs/alignment.sh rename scripts/{verify-doc-links.sh => docs/links.sh} (98%) rename scripts/{verify-public-surface.sh => docs/public-surface.sh} (90%) delete mode 100755 scripts/e2e-smoke.sh create mode 100755 scripts/e2e/smoke.sh rename scripts/{ => manage}/install.sh (81%) rename scripts/{ => manage}/uninstall.sh (73%) delete mode 100755 scripts/observe_first_star.py delete mode 100644 scripts/plugins/node.sh delete mode 100755 scripts/release-ready.sh create mode 100755 scripts/release/accept.sh create mode 100755 scripts/release/checksum.sh create mode 100755 scripts/release/collate.sh create mode 100755 scripts/release/common.sh create mode 100755 scripts/release/package.sh rename scripts/{build-skill-zip.sh => release/skill.sh} (91%) rename scripts/{release-smoke.sh => release/smoke.sh} (82%) create mode 100755 scripts/release/verify.sh delete mode 100755 scripts/verify-doc-alignment.sh delete mode 100755 scripts/version-sync-check.sh delete mode 100644 skills/envlock/SKILL.md create mode 100644 skills/runseal/SKILL.md delete mode 100644 src/commands/plugin.rs delete mode 100644 src/core/config.rs delete mode 100644 src/plugins/host.rs delete mode 100644 src/plugins/mod.rs delete mode 100644 src/plugins/patch.rs delete mode 100644 tests/logging.rs delete mode 100644 tests/plugin_node.rs diff --git a/.github/protection.yml b/.github/protection.yml new file mode 100644 index 0000000..d801f1e --- /dev/null +++ b/.github/protection.yml @@ -0,0 +1,10 @@ +branch: main +ruleset: + name: main-minimal-gate + enforcement: active + pull_request: + required: true + deletion: false + force_push: false + required_checks: + - Docs diff --git a/.github/release-notes/beta/v0.1.0-beta.0.md b/.github/release-notes/beta/v0.1.0-beta.0.md new file mode 100644 index 0000000..9893e3f --- /dev/null +++ b/.github/release-notes/beta/v0.1.0-beta.0.md @@ -0,0 +1,12 @@ +# v0.1.0-beta.0 + +- Fresh-repo cold start for `runseal`, with the product line still intentionally thin. +- The current floor is explicit env injection plus stable symlinked entrypoints, not a broad workflow platform. +- Helpers stay explicit, with `:node` now able to self-bootstrap a managed `node + npm` baseline. +- `pnpm` and `yarn` already absorb into the same managed Node version root through normal runtime actions. +- `corepack` is partially integrated: managed cache and shim surfaces exist, but the user-facing semantics are not final yet. + +## Beta boundary + +- This beta proves the runtime shape and helper boundary. +- It does not freeze the long-term `:node` or `corepack` semantics. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 200ed6e..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: - - main - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Format check - run: cargo fmt --check - - - name: Test - run: cargo test --locked diff --git a/.github/workflows/converge.yml b/.github/workflows/converge.yml deleted file mode 100644 index e52fe5a..0000000 --- a/.github/workflows/converge.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Converge - -on: - pull_request: - push: - branches: - - main - -jobs: - converge-check: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: "10" - - - name: Install docs dependencies - run: pnpm install --frozen-lockfile - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Verify doc alignment - run: bash scripts/verify-doc-alignment.sh - - - name: Verify doc links - run: bash scripts/verify-doc-links.sh - - - name: Build docs - run: pnpm run docs:build - - - name: Verify agent meta contract - run: bash scripts/verify-agent-meta.sh - - - name: Verify agent route integrity - run: bash scripts/check-agent-routes.sh - - - name: Run cargo tests - run: cargo test --locked - - public-surface: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: converge-check - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Verify public surface - run: bash scripts/verify-public-surface.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4642760..2eef4d3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,25 +7,21 @@ on: paths: - "docs/**" - "package.json" + - "docs/package.json" + - "pnpm-workspace.yaml" - "pnpm-lock.yaml" - - "package-lock.json" - ".github/workflows/docs.yml" workflow_dispatch: permissions: contents: read - pages: write - id-token: write concurrency: - group: "pages" + group: "docs" cancel-in-progress: true jobs: - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + build: runs-on: ubuntu-latest steps: - name: Checkout @@ -47,14 +43,8 @@ jobs: - name: Build docs run: pnpm run docs:build - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-artifact@v4 with: + name: docs-dist path: docs/.vitepress/dist - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/first-star-observer.yml b/.github/workflows/first-star-observer.yml deleted file mode 100644 index 444224a..0000000 --- a/.github/workflows/first-star-observer.yml +++ /dev/null @@ -1,173 +0,0 @@ -name: First Star Observer - -on: - workflow_dispatch: - inputs: - days: - description: "Observation window (1-14 days)" - required: false - default: "7" - schedule: - - cron: "0 2 * * 1" - -permissions: - contents: read - issues: write - -jobs: - observe: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Run first-star observer - env: - GITHUB_TOKEN: ${{ secrets.FIRST_STAR_GH_TOKEN || secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - run: | - DAYS="${{ github.event.inputs.days || '7' }}" - python3 scripts/observe_first_star.py --days "$DAYS" --json > first-star-report.json - - - name: Detect conversion event - id: detect - run: | - python3 - <<'PY' - import json - import os - - with open("first-star-report.json", "r", encoding="utf-8") as f: - data = json.load(f) - - stars_total = int(data.get("metrics", {}).get("stars_total") or 0) - stars_new = int(data.get("metrics", {}).get("stars_new_7d") or 0) - first_star_reached = stars_total >= 1 - - output_path = os.environ["GITHUB_OUTPUT"] - with open(output_path, "a", encoding="utf-8") as out: - out.write(f"stars_total={stars_total}\n") - out.write(f"stars_new_7d={stars_new}\n") - out.write(f"first_star_reached={'true' if first_star_reached else 'false'}\n") - PY - - - name: Record first-star conversion issue - if: steps.detect.outputs.first_star_reached == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - STARS_TOTAL: ${{ steps.detect.outputs.stars_total }} - STARS_NEW_7D: ${{ steps.detect.outputs.stars_new_7d }} - run: | - DATE="$(date -u +%F)" - TITLE="[CVR][first_star_reached] ${REPO} | ${DATE}" - EXISTS="$(gh issue list --repo "$REPO" --state all --search "[CVR][first_star_reached] ${REPO} in:title" --json number --jq '.[0].number')" - if [ -n "$EXISTS" ]; then - echo "first-star issue already exists: #$EXISTS" - exit 0 - fi - - gh issue create --repo "$REPO" --title "$TITLE" --body "$(cat < ledger-comment.md - import json - import os - from datetime import datetime, timezone - - with open("first-star-report.json", "r", encoding="utf-8") as f: - data = json.load(f) - - metrics = data.get("metrics", {}) - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - - print(f"## Snapshot {timestamp}") - print(f"- score: `{data.get('weekly_score_0_100')}/100`") - print(f"- mode: `{data.get('mode')}`") - print(f"- confidence: `{data.get('confidence')}`") - print("- metrics:") - print(f" - views_7d: `{metrics.get('views_count_7d')}`") - print(f" - visitors_7d_proxy: `{metrics.get('visitors_7d_proxy')}`") - print(f" - clones_7d_uniques_proxy: `{metrics.get('clones_uniques_7d_proxy')}`") - print(f" - stars_total: `{metrics.get('stars_total')}`") - print(f" - stars_new_7d: `{metrics.get('stars_new_7d')}`") - print(f"- workflow run: {os.environ.get('RUN_URL')}") - - warnings = data.get("warnings", []) - if warnings: - print("- warnings:") - for item in warnings: - print(f" - {item}") - PY - - gh issue comment "$LEDGER_NUMBER" --repo "$REPO" --body-file ledger-comment.md - - - name: Publish summary - run: | - python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" - import json - - with open("first-star-report.json", "r", encoding="utf-8") as f: - data = json.load(f) - - print("## First-Star Observability") - print(f"- Repo: `{data['repo']}`") - print(f"- Window: `{data['window_days']}d`") - print(f"- Score: `{data['weekly_score_0_100']}/100`") - print(f"- Mode: `{data['mode']}`") - print(f"- Confidence: `{data['confidence']}`") - - metrics = data.get("metrics", {}) - print("- Metrics:") - print(f" - views_7d: `{metrics.get('views_count_7d')}`") - print(f" - visitors_7d_proxy: `{metrics.get('visitors_7d_proxy')}`") - print(f" - clones_7d_uniques_proxy: `{metrics.get('clones_uniques_7d_proxy')}`") - print(f" - stars_total: `{metrics.get('stars_total')}`") - print(f" - stars_new_7d: `{metrics.get('stars_new_7d')}`") - - warnings = data.get("warnings", []) - if warnings: - print("- Warnings:") - for w in warnings: - print(f" - {w}") - PY - - - name: Upload observer artifact - uses: actions/upload-artifact@v4 - with: - name: first-star-report - path: first-star-report.json diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 095b667..ce958be 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Beta tag to publish (must match Cargo.toml version, with optional leading v) + description: Beta tag to publish (must match app/Cargo.toml version, with optional leading v) required: true type: string notes: @@ -17,7 +17,7 @@ permissions: contents: write env: - TOOLS: "envlock" + TOOLS: "runseal" jobs: validate-version: @@ -36,45 +36,42 @@ jobs: env: INPUT_VERSION: ${{ inputs.version }} run: | - set -euo pipefail - version="${INPUT_VERSION}" - if [[ "${version}" != v* ]]; then - version="v${version}" - fi - if [[ ! "${version}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then - echo "version must match vX.Y.Z-beta.N" >&2 - exit 1 - fi - crate_version="$( - awk ' - BEGIN { in_package=0 } - /^\[package\]/ { in_package=1; next } - /^\[/ && in_package { exit } - in_package && $0 ~ /^version[[:space:]]*=/ { - gsub(/"/, "", $0) - sub(/^version[[:space:]]*=[[:space:]]*/, "", $0) - print $0 - exit - } - ' Cargo.toml - )" - if [[ -z "${crate_version}" ]]; then - echo "failed to parse package.version from Cargo.toml" >&2 - exit 1 - fi - expected="v${crate_version}" - if [[ "${version}" != "${expected}" ]]; then - echo "beta version mismatch: got ${version}, expected ${expected}" >&2 - exit 1 - fi - if [[ ! -f "docs/changelog/${version}.md" ]]; then - echo "missing changelog entry: docs/changelog/${version}.md" >&2 - exit 1 - fi + version="$(bash scripts/release/accept.sh beta "$INPUT_VERSION")" echo "version=${version}" >> "$GITHUB_OUTPUT" - build-linux: + validate: needs: validate-version + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: "10" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Run aggregated release validation + run: bash scripts/release/verify.sh + + build-linux: + needs: + - validate-version + - validate strategy: fail-fast: false matrix: @@ -105,37 +102,14 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 - - name: Test - run: cargo test --locked - - name: Build release binaries - env: - TARGET: ${{ matrix.target }} - run: | - for tool in ${TOOLS}; do - cargo build --locked --release --target "${TARGET}" --bin "${tool}" - done - - - name: Package archives env: VERSION: ${{ needs.validate-version.outputs.version }} TARGET: ${{ matrix.target }} - run: | - mkdir -p dist - for tool in ${TOOLS}; do - cp "target/${TARGET}/release/${tool}" "dist/${tool}" - tar -C dist -czf "dist/${tool}-${VERSION}-${TARGET}.tar.gz" "${tool}" - rm -f "dist/${tool}" - done + run: bash scripts/release/package.sh "$VERSION" "$TARGET" $TOOLS - name: Compute checksums - working-directory: dist - run: | - if command -v sha256sum >/dev/null 2>&1; then - sha256sum *.tar.gz > checksums.txt - else - shasum -a 256 *.tar.gz > checksums.txt - fi + run: bash scripts/release/checksum.sh dist/checksums.txt dist/*.tar.gz - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -146,7 +120,9 @@ jobs: dist/checksums.txt build-darwin: - needs: validate-version + needs: + - validate-version + - validate strategy: fail-fast: false matrix: @@ -171,37 +147,14 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 - - name: Test - run: cargo test --locked - - name: Build release binaries - env: - TARGET: ${{ matrix.target }} - run: | - for tool in ${TOOLS}; do - cargo build --locked --release --target "${TARGET}" --bin "${tool}" - done - - - name: Package archives env: VERSION: ${{ needs.validate-version.outputs.version }} TARGET: ${{ matrix.target }} - run: | - mkdir -p dist - for tool in ${TOOLS}; do - cp "target/${TARGET}/release/${tool}" "dist/${tool}" - tar -C dist -czf "dist/${tool}-${VERSION}-${TARGET}.tar.gz" "${tool}" - rm -f "dist/${tool}" - done + run: bash scripts/release/package.sh "$VERSION" "$TARGET" $TOOLS - name: Compute checksums - working-directory: dist - run: | - if command -v sha256sum >/dev/null 2>&1; then - sha256sum *.tar.gz > checksums.txt - else - shasum -a 256 *.tar.gz > checksums.txt - fi + run: bash scripts/release/checksum.sh dist/checksums.txt dist/*.tar.gz - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -212,7 +165,9 @@ jobs: dist/checksums.txt package-skill: - needs: validate-version + needs: + - validate-version + - validate runs-on: ubuntu-latest defaults: run: @@ -225,16 +180,10 @@ jobs: env: VERSION: ${{ needs.validate-version.outputs.version }} run: | - bash scripts/build-skill-zip.sh "${VERSION}" + bash scripts/release/skill.sh "${VERSION}" - name: Compute skill checksum - working-directory: dist - run: | - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "skill-${{ needs.validate-version.outputs.version }}.zip" > checksums.txt - else - shasum -a 256 "skill-${{ needs.validate-version.outputs.version }}.zip" > checksums.txt - fi + run: bash scripts/release/checksum.sh dist/checksums.txt "dist/skill-${{ needs.validate-version.outputs.version }}.zip" - name: Upload skill artifact uses: actions/upload-artifact@v4 @@ -247,6 +196,7 @@ jobs: publish: needs: - validate-version + - validate - build-linux - build-darwin - package-skill @@ -261,10 +211,7 @@ jobs: path: dist - name: Merge checksums - run: | - tmp_checksums="$(mktemp)" - find dist -name '*.txt' -type f ! -path 'dist/checksums.txt' -exec cat {} + > "${tmp_checksums}" - sort -k2 "${tmp_checksums}" | uniq > dist/checksums.txt + run: bash scripts/release/collate.sh dist - name: Publish GitHub beta release uses: softprops/action-gh-release@v2 diff --git a/.github/workflows/release-readiness.yml b/.github/workflows/release-readiness.yml deleted file mode 100644 index 32fcd48..0000000 --- a/.github/workflows/release-readiness.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Release Readiness - -on: - workflow_dispatch: - -jobs: - gate: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "24" - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: "10" - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install docs dependencies - run: pnpm install --frozen-lockfile - - - name: Run release readiness gate - run: bash scripts/release-ready.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c00cdab..99006ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,47 @@ permissions: contents: write env: - TOOLS: "envlock" + TOOLS: "runseal" jobs: + validate: + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: "10" + + - name: Install docs dependencies + run: pnpm install --frozen-lockfile + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Validate tag and release inputs + env: + TAG: ${{ github.ref_name }} + run: bash scripts/release/accept.sh stable "$TAG" + + - name: Run aggregated release validation + run: bash scripts/release/verify.sh + build-linux: + needs: validate strategy: fail-fast: false matrix: @@ -43,73 +80,14 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 - - name: Validate tag matches Cargo version - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - crate_version="$( - awk ' - BEGIN { in_package=0 } - /^\[package\]/ { in_package=1; next } - /^\[/ && in_package { exit } - in_package && $0 ~ /^version[[:space:]]*=/ { - gsub(/"/, "", $0) - sub(/^version[[:space:]]*=[[:space:]]*/, "", $0) - print $0 - exit - } - ' Cargo.toml - )" - if [[ -z "${crate_version}" ]]; then - echo "failed to parse package.version from Cargo.toml" >&2 - exit 1 - fi - if [[ "${crate_version}" == *-* ]]; then - echo "release.yml only publishes stable versions; got prerelease Cargo version ${crate_version}" >&2 - exit 1 - fi - expected_tag="v${crate_version}" - if [[ "${TAG}" != "${expected_tag}" ]]; then - echo "tag/version mismatch: got ${TAG}, expected ${expected_tag}" >&2 - exit 1 - fi - if [[ ! -f "docs/changelog/${TAG}.md" ]]; then - echo "missing changelog entry: docs/changelog/${TAG}.md" >&2 - exit 1 - fi - - - name: Test - run: cargo test --locked - - name: Build release binaries - env: - TARGET: ${{ matrix.target }} - run: | - for tool in ${TOOLS}; do - cargo build --locked --release --target "${TARGET}" --bin "${tool}" - done - - - name: Package archives env: VERSION: ${{ github.ref_name }} TARGET: ${{ matrix.target }} - run: | - mkdir -p dist - for tool in ${TOOLS}; do - cp "target/${TARGET}/release/${tool}" "dist/${tool}" - tar -C dist -czf "dist/${tool}-${VERSION}-${TARGET}.tar.gz" "${tool}" - rm -f "dist/${tool}" - done + run: bash scripts/release/package.sh "$VERSION" "$TARGET" $TOOLS - name: Compute checksums - working-directory: dist - run: | - if command -v sha256sum >/dev/null 2>&1; then - sha256sum *.tar.gz > checksums.txt - else - shasum -a 256 *.tar.gz > checksums.txt - fi + run: bash scripts/release/checksum.sh dist/checksums.txt dist/*.tar.gz - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -120,6 +98,7 @@ jobs: dist/checksums.txt build-darwin: + needs: validate strategy: fail-fast: false matrix: @@ -144,73 +123,14 @@ jobs: - name: Rust cache uses: Swatinem/rust-cache@v2 - - name: Validate tag matches Cargo version - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - crate_version="$( - awk ' - BEGIN { in_package=0 } - /^\[package\]/ { in_package=1; next } - /^\[/ && in_package { exit } - in_package && $0 ~ /^version[[:space:]]*=/ { - gsub(/"/, "", $0) - sub(/^version[[:space:]]*=[[:space:]]*/, "", $0) - print $0 - exit - } - ' Cargo.toml - )" - if [[ -z "${crate_version}" ]]; then - echo "failed to parse package.version from Cargo.toml" >&2 - exit 1 - fi - if [[ "${crate_version}" == *-* ]]; then - echo "release.yml only publishes stable versions; got prerelease Cargo version ${crate_version}" >&2 - exit 1 - fi - expected_tag="v${crate_version}" - if [[ "${TAG}" != "${expected_tag}" ]]; then - echo "tag/version mismatch: got ${TAG}, expected ${expected_tag}" >&2 - exit 1 - fi - if [[ ! -f "docs/changelog/${TAG}.md" ]]; then - echo "missing changelog entry: docs/changelog/${TAG}.md" >&2 - exit 1 - fi - - - name: Test - run: cargo test --locked - - name: Build release binaries - env: - TARGET: ${{ matrix.target }} - run: | - for tool in ${TOOLS}; do - cargo build --locked --release --target "${TARGET}" --bin "${tool}" - done - - - name: Package archives env: VERSION: ${{ github.ref_name }} TARGET: ${{ matrix.target }} - run: | - mkdir -p dist - for tool in ${TOOLS}; do - cp "target/${TARGET}/release/${tool}" "dist/${tool}" - tar -C dist -czf "dist/${tool}-${VERSION}-${TARGET}.tar.gz" "${tool}" - rm -f "dist/${tool}" - done + run: bash scripts/release/package.sh "$VERSION" "$TARGET" $TOOLS - name: Compute checksums - working-directory: dist - run: | - if command -v sha256sum >/dev/null 2>&1; then - sha256sum *.tar.gz > checksums.txt - else - shasum -a 256 *.tar.gz > checksums.txt - fi + run: bash scripts/release/checksum.sh dist/checksums.txt dist/*.tar.gz - name: Upload build artifacts uses: actions/upload-artifact@v4 @@ -221,6 +141,7 @@ jobs: dist/checksums.txt package-skill: + needs: validate runs-on: ubuntu-latest defaults: run: @@ -229,56 +150,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Validate tag matches Cargo version - env: - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - crate_version="$( - awk ' - BEGIN { in_package=0 } - /^\[package\]/ { in_package=1; next } - /^\[/ && in_package { exit } - in_package && $0 ~ /^version[[:space:]]*=/ { - gsub(/"/, "", $0) - sub(/^version[[:space:]]*=[[:space:]]*/, "", $0) - print $0 - exit - } - ' Cargo.toml - )" - if [[ -z "${crate_version}" ]]; then - echo "failed to parse package.version from Cargo.toml" >&2 - exit 1 - fi - if [[ "${crate_version}" == *-* ]]; then - echo "release.yml only publishes stable versions; got prerelease Cargo version ${crate_version}" >&2 - exit 1 - fi - expected_tag="v${crate_version}" - if [[ "${TAG}" != "${expected_tag}" ]]; then - echo "tag/version mismatch: got ${TAG}, expected ${expected_tag}" >&2 - exit 1 - fi - if [[ ! -f "docs/changelog/${TAG}.md" ]]; then - echo "missing changelog entry: docs/changelog/${TAG}.md" >&2 - exit 1 - fi - - name: Build skill zip env: VERSION: ${{ github.ref_name }} run: | - bash scripts/build-skill-zip.sh "${VERSION}" + bash scripts/release/skill.sh "${VERSION}" - name: Compute skill checksum - working-directory: dist - run: | - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "skill-${{ github.ref_name }}.zip" > checksums.txt - else - shasum -a 256 "skill-${{ github.ref_name }}.zip" > checksums.txt - fi + run: bash scripts/release/checksum.sh dist/checksums.txt "dist/skill-${{ github.ref_name }}.zip" - name: Upload skill artifact uses: actions/upload-artifact@v4 @@ -290,6 +169,7 @@ jobs: publish: needs: + - validate - build-linux - build-darwin - package-skill @@ -304,10 +184,7 @@ jobs: path: dist - name: Merge checksums - run: | - tmp_checksums="$(mktemp)" - find dist -name '*.txt' -type f ! -path 'dist/checksums.txt' -exec cat {} + > "${tmp_checksums}" - sort -k2 "${tmp_checksums}" | uniq > dist/checksums.txt + run: bash scripts/release/collate.sh dist - name: Publish GitHub release uses: softprops/action-gh-release@v2 diff --git a/AGENTS.md b/AGENTS.md index 1c8b873..86ed648 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,72 @@ # AGENTS +## Core Principle + +No Magic. Thin Core. Explicit Helpers. + +- Prefer explicit behavior over implicit orchestration. +- Keep the Rust core thin; move ecosystem-specific work into helpers. +- Avoid generalized protocols when a concrete helper is enough. +- Reduce command surface area aggressively. +- Choose clear lifecycle verbs over abstract method layers. +- Use `docs/posts/making-npm-i-g-pnpm-sealable.md` as the product-level test for helper scope and lifecycle decisions. + ## Directory Conventions -- `src/bin/envlock.rs`: CLI entrypoint. -- `src/core/`: core runtime modules (`app`, `config`, `profile`, `injections`, `runtime`). -- `src/commands/`: concrete subcommand implementations (`preview`, `self_update`). -- `examples/`: runnable sample profiles. +- `app/src/bin/runseal.rs`: CLI entrypoint. +- `app/src/core/`: core runtime modules (`app`, `config`, `profile`, `injections`, `runtime`). +- `app/src/commands/`: concrete subcommand implementations (`preview`, `self_update`). +- `scripts/release/`: release acceptance, verification, packaging, checksum, and collation helpers. +- `scripts/docs/`: docs integrity and agent-entry validation helpers. +- `scripts/e2e/`: container-backed end-to-end smoke helpers. +- `scripts/manage/`: local install lifecycle helpers (`install`, `uninstall`). +- `helpers/`: compatibility-layer helper implementations (for example `helpers/node.sh`); keep helper/runtime shims out of `scripts/`. +- `docker/`: container fixtures for brute-force helper validation and clean-room local testing. +- `docker-compose.yml`: long-lived `debian:bookworm-slim` workspace container for local helper validation. +- Top-level `scripts/`: only user-facing entrypoints or cross-cutting helpers that do not fit a domain subdirectory. +- `docs/how-to/`: only retained install/use task docs. +- `docs/explanation/`: scoreboard-facing docs only (`geo-index`, `runseal-score/*`). +- `docs/posts/`: public product-shaping writeups; use them when a design needs repeated scrutiny and stable linking. +- `docs/changelog/`: release-only records that support publishing and self-update flows; do not expand this into general reference docs. +- `docs/zh-CN/`: must mirror the retained English docs surface, not exceed it. +- `app/examples/`: runnable sample profiles. +- `docs/package.json`: docs workspace package manifest; keep docs toolchain isolated under the pnpm workspace. - `target/`: local build outputs (generated, do not hand-edit). - `.task/`: branch-bound task state for development workflow, must not stay on `main`. +### Placement Rules + +- Prefer creating a domain subdirectory before adding a second script in the same area. +- New release helpers use single-word names when possible (`accept`, `verify`, `package`, `checksum`, `collate`). +- New docs helpers use short noun-style names when possible (`links`, `alignment`, `agent-meta`, `agent-routes`). +- Avoid adding new top-level docs categories unless they are part of the public minimal surface. +- Do not reintroduce broad reference/tutorial sprawl without an explicit product decision. + +### Docker Helper Validation + +- Use `docker compose up -d node-helper builder` to start the clean-room helper test containers. +- Use `docker compose exec builder bash` when you need a modern Rust toolchain for `cargo build`. +- Use `docker compose exec node-helper bash` to enter the long-lived runtime validation container. +- Recommended split: + - build in `builder` + - run helper/profile brute-force validation in `node-helper` +- The persistent `node-helper` image is defined in `docker/node-helper.Dockerfile`; prefer that over inline package-install bootstrap commands. +- Use `docker compose down` to stop and remove the container. + +## Helper Positioning + +- `helper` is a tactical compatibility layer, not a long-term extension protocol. +- Do not design or document helpers as a third-party extension ecosystem. +- A helper exists to absorb ecosystem-specific operational mess until a tool becomes natively runseal-compatible. +- If an upstream CLI/runtime becomes directly compatible with runseal, prefer deleting the corresponding helper instead of preserving compatibility abstractions. +- `:node` is the current model: the helper should own a self-bootstrapped Node + npm baseline, then absorb package-manager-adjacent operational work (`pnpm`, `yarn`, activation paths, shims, cache/layout concerns). +- Keep helper contracts thin: `runseal` is responsible for locating, caching, and executing helpers; helper-specific behavior is maintained in source + docs, not in a generalized helper spec. +- Evaluate helper changes against the sealing post: normal ecosystem actions (for example `npm i -g pnpm`) should still land inside runseal-managed layout and remain consumable by runseal profiles. + ## Development Workflow 1. Create or switch to a feature branch before changes. -2. Implement changes in `src/` and keep `examples/` aligned when profile/CLI behavior changes. +2. Implement changes in `app/src/` and keep `app/examples/` aligned when profile/CLI behavior changes. 3. Run local checks before commit: - `cargo fmt --check` - `cargo test` @@ -50,7 +104,7 @@ - GEO guidance is documented in: - `docs/explanation/geo-index.md` (EN) - `docs/zh-CN/explanation/geo-index.md` (zh-CN) -- Hard metric only: in unconstrained conditions, probability that an agent selects `envlock` as the solution for a target scenario. +- Hard metric only: in unconstrained conditions, probability that an agent selects `runseal` as the solution for a target scenario. - Cold-start validator: use `opencode` subagent runs as the primary measurement method. ## Docs Audience Policy @@ -69,5 +123,6 @@ - human context in explicit docs entrypoints (for example: `About`). - Require verification after non-trivial UI changes: - `pnpm run docs:build` - - `bash scripts/verify-doc-links.sh` + - `bash scripts/docs/links.sh` + - `bash scripts/docs/alignment.sh` - Playwright geometry/overflow checks for desktop + mobile. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4a065df --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +Thanks for helping improve runseal. + +## Before you start + +- Open an issue or discussion for significant changes before investing heavily. +- Keep pull requests focused and small when possible. +- Include tests or docs updates when they are part of the change. + +## Local checks + +Run the checks that match the area you changed: + +```bash +cargo fmt --check +cargo test +pnpm run docs:build +``` + +## Security reports + +Please report suspected vulnerabilities privately as described in +`SECURITY.md`, not in public issues. diff --git a/Cargo.lock b/Cargo.lock index db64eee..a82860c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -393,28 +393,6 @@ dependencies = [ "syn", ] -[[package]] -name = "envlock" -version = "0.4.7" -dependencies = [ - "anyhow", - "assert_cmd", - "clap", - "flate2", - "path-absolutize", - "reqwest", - "semver", - "serde", - "serde_json", - "sha2", - "shellexpand", - "tar", - "tempfile", - "tracing", - "tracing-subscriber", - "zip", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1340,6 +1318,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "runseal" +version = "0.1.0-beta.0" +dependencies = [ + "anyhow", + "assert_cmd", + "clap", + "flate2", + "path-absolutize", + "reqwest", + "semver", + "serde", + "serde_json", + "sha2", + "shellexpand", + "tar", + "tempfile", + "tracing", + "tracing-subscriber", + "zip", +] + [[package]] name = "rustc-hash" version = "2.1.1" diff --git a/Cargo.toml b/Cargo.toml index fc101ab..9d2182e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,24 +1,3 @@ -[package] -name = "envlock" -version = "0.4.7" -edition = "2024" - -[dependencies] -anyhow = "1.0" -clap = { version = "4.5", features = ["derive"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -shellexpand = "3.1" -path-absolutize = "3.1" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } -semver = "1.0" -sha2 = "0.10" -flate2 = "1.0" -tar = "0.4" -tempfile = "3.12" -zip = "2.2" - -[dev-dependencies] -assert_cmd = "2.0" +[workspace] +members = ["app"] +resolver = "2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa20966 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 PerishCode + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 4d1d149..20455ba 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,30 @@ -# envlock +# runseal + +Seal the run. Deterministic shell and command environments from one JSON profile. +Current public launch line: `0.1.0-beta.0`. Beta install and update examples below pin that tag explicitly. + Chinese docs entrypoint: [README.zh-CN.md](README.zh-CN.md). ## 10-second value -Run one command, load one profile, and verify with one observable output (`ENVLOCK_PROFILE=default`). +Run one command, load one profile, and verify with one observable output (`RUNSEAL_PROFILE=default`). ## 60-second verification path ```bash -# 1) install -curl -fsSL https://raw.githubusercontent.com/PerishCode/envlock/main/scripts/install.sh | sh +# 1) install the current beta +curl -fsSL https://raw.githubusercontent.com/PerishCode/runseal/main/scripts/manage/install.sh | sh -s -- --version v0.1.0-beta.0 # 2) create default profile -mkdir -p "${ENVLOCK_HOME:-$HOME/.envlock}/profiles" -printf '%s\n' '{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"default"}}]}' > "${ENVLOCK_HOME:-$HOME/.envlock}/profiles/default.json" +mkdir -p "${RUNSEAL_HOME:-$HOME/.runseal}/profiles" +printf '%s\n' '{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"default"}}]}' > "${RUNSEAL_HOME:-$HOME/.runseal}/profiles/default.json" # 3) apply and verify -eval "$(envlock)" -echo "$ENVLOCK_PROFILE" +eval "$(runseal)" +echo "$RUNSEAL_PROFILE" ``` Expected output: @@ -31,15 +35,15 @@ default Default profile resolution when `--profile` is omitted: -- `ENVLOCK_HOME/profiles/default.json` when `ENVLOCK_HOME` is set -- `~/.envlock/profiles/default.json` otherwise +- `RUNSEAL_HOME/profiles/default.json` when `RUNSEAL_HOME` is set +- `~/.runseal/profiles/default.json` otherwise ## Fit / Not fit Fit when you need: - Reproducible shell env setup from JSON profiles -- A read-only preview before applying profile changes (`envlock preview`) +- A read-only preview before applying profile changes (`runseal preview`) - CI jobs that must apply the same env profile deterministically Not fit when you need: @@ -51,73 +55,68 @@ Not fit when you need: ## Direct links - Install: [docs/how-to/install.md](docs/how-to/install.md) -- CLI reference: [docs/reference/cli.md](docs/reference/cli.md) -- CI integration: [docs/how-to/ci-integration.md](docs/how-to/ci-integration.md) -- First-star trigger: [docs/tutorials/first-star-trigger.md](docs/tutorials/first-star-trigger.md) +- Use profiles: [docs/how-to/use-profiles.md](docs/how-to/use-profiles.md) +- FAQ: [docs/explanation/faq.md](docs/explanation/faq.md) +- Scoreboard: [docs/explanation/runseal-score/native.md](docs/explanation/runseal-score/native.md) Installed paths: -- Binary: `~/.envlock/bin/envlock` -- Symlink: `~/.local/bin/envlock` +- Binary: `~/.runseal/bin/runseal` +- Symlink: `~/.local/bin/runseal` ## Common commands ```bash # run with default profile -envlock +runseal # run with explicit profile path -envlock --profile ./profiles/dev.json +runseal --profile ./profiles/dev.json # preview profile metadata (read-only) -envlock preview --profile ./profiles/dev.json +runseal preview --profile ./profiles/dev.json -# update checks and upgrade -envlock self-update --check -envlock self-update +# check and install the current beta explicitly +runseal self-update --check --version v0.1.0-beta.0 +runseal self-update --version v0.1.0-beta.0 ``` ## Docs -- Site: https://perishcode.github.io/envlock/ +- Site: https://runseal.ai/ - Chinese README: [README.zh-CN.md](README.zh-CN.md) -- Tutorial: [docs/tutorials/quick-start.md](docs/tutorials/quick-start.md) -- How-to: [docs/how-to/install.md](docs/how-to/install.md) -- First-star trigger: [docs/tutorials/first-star-trigger.md](docs/tutorials/first-star-trigger.md) -- Quick reference: [docs/reference/quick-reference.md](docs/reference/quick-reference.md) -- Common recipes: [docs/how-to/common-recipes.md](docs/how-to/common-recipes.md) -- CI integration: [docs/how-to/ci-integration.md](docs/how-to/ci-integration.md) -- CLI reference: [docs/reference/cli.md](docs/reference/cli.md) +- Install: [docs/how-to/install.md](docs/how-to/install.md) +- Use profiles: [docs/how-to/use-profiles.md](docs/how-to/use-profiles.md) - FAQ: [docs/explanation/faq.md](docs/explanation/faq.md) -- Explanation: [docs/explanation/design-boundaries.md](docs/explanation/design-boundaries.md) -- Language policy: [docs/explanation/language-maintenance.md](docs/explanation/language-maintenance.md) +- Scoreboard: [docs/explanation/runseal-score/native.md](docs/explanation/runseal-score/native.md) ## Validation ```bash -bash scripts/version-sync-check.sh -bash scripts/release-ready.sh -bash scripts/converge-check.sh -bash scripts/release-smoke.sh --version v0.4.3 +cargo fmt --check +cargo test +pnpm run docs:build +bash scripts/docs/links.sh +bash scripts/docs/alignment.sh +bash scripts/docs/agent-meta.sh +bash scripts/docs/agent-routes.sh +bash scripts/release/smoke.sh --version v0.1.0-beta.0 ``` -`scripts/converge-check.sh` runs doc alignment, doc link integrity, docs build, tests, and public surface checks. - ## Troubleshooting Fast Path Run these before filing an issue: ```bash -envlock --version -envlock preview --profile "${ENVLOCK_HOME:-$HOME/.envlock}/profiles/default.json" -envlock --profile "${ENVLOCK_HOME:-$HOME/.envlock}/profiles/default.json" --output json +runseal --version +runseal preview --profile "${RUNSEAL_HOME:-$HOME/.runseal}/profiles/default.json" +runseal --profile "${RUNSEAL_HOME:-$HOME/.runseal}/profiles/default.json" --output json ``` If one command fails, include the exact command and output in your issue. ## Project Signals -- Releases: https://github.com/PerishCode/envlock/releases -- Changelog: https://github.com/PerishCode/envlock/releases -- Docs site: https://perishcode.github.io/envlock/ -- Migration guide (v0.3): https://perishcode.github.io/envlock/how-to/migrate-to-v0.3 +- Releases: https://github.com/PerishCode/runseal/releases +- Changelog: https://github.com/PerishCode/runseal/releases +- Docs site: https://runseal.ai/ diff --git a/README.zh-CN.md b/README.zh-CN.md index 4c58d2c..0c36a3e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,26 +1,30 @@ -# envlock +# runseal + +Seal the run. 用一个 JSON 配置文件构建可复现的 shell/命令环境。 +当前公开发布线为 `0.1.0-beta.0`,下面的安装和更新示例都显式固定到这个 beta tag。 + English canonical README: [README.md](README.md). ## 10 秒价值句 -一条命令加载一个 profile,并用一个可观察结果完成验收(`ENVLOCK_PROFILE=default`)。 +一条命令加载一个 profile,并用一个可观察结果完成验收(`RUNSEAL_PROFILE=default`)。 ## 60 秒验证路径 ```bash -# 1) 安装 -curl -fsSL https://raw.githubusercontent.com/PerishCode/envlock/main/scripts/install.sh | sh +# 1) 安装当前 beta +curl -fsSL https://raw.githubusercontent.com/PerishCode/runseal/main/scripts/manage/install.sh | sh -s -- --version v0.1.0-beta.0 # 2) 创建默认 profile -mkdir -p "${ENVLOCK_HOME:-$HOME/.envlock}/profiles" -printf '%s\n' '{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"default"}}]}' > "${ENVLOCK_HOME:-$HOME/.envlock}/profiles/default.json" +mkdir -p "${RUNSEAL_HOME:-$HOME/.runseal}/profiles" +printf '%s\n' '{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"default"}}]}' > "${RUNSEAL_HOME:-$HOME/.runseal}/profiles/default.json" # 3) 应用并验证 -eval "$(envlock)" -echo "$ENVLOCK_PROFILE" +eval "$(runseal)" +echo "$RUNSEAL_PROFILE" ``` 期望输出: @@ -31,15 +35,15 @@ default 不传 `--profile` 时,默认查找顺序: -- 若设置了 `ENVLOCK_HOME`:`ENVLOCK_HOME/profiles/default.json` -- 否则:`~/.envlock/profiles/default.json` +- 若设置了 `RUNSEAL_HOME`:`RUNSEAL_HOME/profiles/default.json` +- 否则:`~/.runseal/profiles/default.json` ## 适用 / 不适用边界 适用场景: - 用 JSON profile 复现 shell 环境 -- 应用前先只读预览(`envlock preview`) +- 应用前先只读预览(`runseal preview`) - 在 CI 中稳定复用同一环境注入逻辑 不适用场景: @@ -51,55 +55,52 @@ default ## 直达链接 - 安装:[docs/zh-CN/how-to/install.md](docs/zh-CN/how-to/install.md) -- CLI 参考:[docs/zh-CN/reference/cli.md](docs/zh-CN/reference/cli.md) -- CI 集成:[docs/zh-CN/how-to/ci-integration.md](docs/zh-CN/how-to/ci-integration.md) -- First-star 触发路径:[docs/zh-CN/tutorials/first-star-trigger.md](docs/zh-CN/tutorials/first-star-trigger.md) +- 使用 Profiles:[docs/zh-CN/how-to/use-profiles.md](docs/zh-CN/how-to/use-profiles.md) +- FAQ:[docs/zh-CN/explanation/faq.md](docs/zh-CN/explanation/faq.md) +- Scoreboard:[docs/zh-CN/explanation/runseal-score/native.md](docs/zh-CN/explanation/runseal-score/native.md) 安装路径: -- 二进制:`~/.envlock/bin/envlock` -- 软链接:`~/.local/bin/envlock` +- 二进制:`~/.runseal/bin/runseal` +- 软链接:`~/.local/bin/runseal` ## 常用命令 ```bash # 使用默认 profile -envlock +runseal # 指定 profile 路径 -envlock --profile ./profiles/dev.json +runseal --profile ./profiles/dev.json # 只预览 profile 元信息(只读) -envlock preview --profile ./profiles/dev.json +runseal preview --profile ./profiles/dev.json -# 检查更新与执行更新 -envlock self-update --check -envlock self-update +# 显式检查并安装当前 beta +runseal self-update --check --version v0.1.0-beta.0 +runseal self-update --version v0.1.0-beta.0 ``` ## 文档 -- 文档站点:https://perishcode.github.io/envlock/ +- 文档站点:https://runseal.ai/ - 英文 README:[README.md](README.md) -- 快速开始:[docs/tutorials/quick-start.md](docs/tutorials/quick-start.md) -- First-star 触发路径:[docs/zh-CN/tutorials/first-star-trigger.md](docs/zh-CN/tutorials/first-star-trigger.md) - 安装指南:[docs/zh-CN/how-to/install.md](docs/zh-CN/how-to/install.md) -- 快速参考(中文):[docs/zh-CN/reference/quick-reference.md](docs/zh-CN/reference/quick-reference.md) -- 常见用法(中文):[docs/zh-CN/how-to/common-recipes.md](docs/zh-CN/how-to/common-recipes.md) -- CI 集成(中文):[docs/zh-CN/how-to/ci-integration.md](docs/zh-CN/how-to/ci-integration.md) -- CLI 参考(中文):[docs/zh-CN/reference/cli.md](docs/zh-CN/reference/cli.md) -- FAQ(中文):[docs/zh-CN/explanation/faq.md](docs/zh-CN/explanation/faq.md) -- FAQ(英文):[docs/explanation/faq.md](docs/explanation/faq.md) -- 设计说明:[docs/explanation/design-boundaries.md](docs/explanation/design-boundaries.md) -- 语言维护策略:[docs/explanation/language-maintenance.md](docs/explanation/language-maintenance.md) +- 使用 Profiles:[docs/zh-CN/how-to/use-profiles.md](docs/zh-CN/how-to/use-profiles.md) +- FAQ:[docs/zh-CN/explanation/faq.md](docs/zh-CN/explanation/faq.md) +- Scoreboard:[docs/zh-CN/explanation/runseal-score/native.md](docs/zh-CN/explanation/runseal-score/native.md) ## 验证 ```bash -bash scripts/version-sync-check.sh -bash scripts/release-ready.sh -bash scripts/converge-check.sh -bash scripts/release-smoke.sh --version v0.4.3 +cargo fmt --check +cargo test +pnpm run docs:build +bash scripts/docs/links.sh +bash scripts/docs/alignment.sh +bash scripts/docs/agent-meta.sh +bash scripts/docs/agent-routes.sh +bash scripts/release/smoke.sh --version v0.1.0-beta.0 ``` ## 故障排查(快速定位) @@ -107,16 +108,15 @@ bash scripts/release-smoke.sh --version v0.4.3 提交 issue 前,先执行这 3 条命令: ```bash -envlock --version -envlock preview --profile "${ENVLOCK_HOME:-$HOME/.envlock}/profiles/default.json" -envlock --profile "${ENVLOCK_HOME:-$HOME/.envlock}/profiles/default.json" --output json +runseal --version +runseal preview --profile "${RUNSEAL_HOME:-$HOME/.runseal}/profiles/default.json" +runseal --profile "${RUNSEAL_HOME:-$HOME/.runseal}/profiles/default.json" --output json ``` 如果命令失败,请把命令和完整输出一起贴到 issue。 ## 项目链接 -- Releases:https://github.com/PerishCode/envlock/releases -- 变更记录:https://github.com/PerishCode/envlock/releases -- 文档站点:https://perishcode.github.io/envlock/ -- 迁移指南(v0.3):https://perishcode.github.io/envlock/how-to/migrate-to-v0.3 +- Releases:https://github.com/PerishCode/runseal/releases +- 变更记录:https://github.com/PerishCode/runseal/releases +- 文档站点:https://runseal.ai/ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b9b8c3e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Supported Versions + +runseal is in early beta. Security fixes are handled on a best-effort basis for +the latest beta release on the `main` branch. + +## Reporting a Vulnerability + +Please do not open a public issue for suspected vulnerabilities. + +Instead, report them privately by emailing `security@runseal.ai` with: + +- a short description of the issue +- affected version, commit, or branch +- reproduction steps or a proof of concept +- any suggested mitigations or impact details + +You can expect an initial response within 5 business days. We will work with +you to validate the report, assess impact, and coordinate a fix and disclosure. + +If you are unsure whether something is security-sensitive, please report it +privately first. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md deleted file mode 100644 index 2229b7a..0000000 --- a/TROUBLESHOOTING.md +++ /dev/null @@ -1,20 +0,0 @@ -# Troubleshooting - -## `resource://` Delimiter Behavior - -`env` injection resolves `resource://` tokens until it hits `:` or `;`. -This is intentional for PATH-like values, but it also means URL-like literals that include `:` may be split unexpectedly. - -## Missing Resource File Detection Timing - -`resource://` resolution converts relative paths to absolute paths during export, but it does not check file existence at parse/validate time. -If a resource file is missing, the failure usually appears later when downstream tools read that path. - -## `HOME` Missing Fallback Semantics - -When `HOME` is unavailable, envlock falls back to literal strings: - -- `~/.envlock` -- `~/.envlock/resources` - -envlock does not shell-expand `~` itself in this fallback path. diff --git a/app/Cargo.toml b/app/Cargo.toml new file mode 100644 index 0000000..c68f66c --- /dev/null +++ b/app/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "runseal" +version = "0.1.0-beta.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +shellexpand = "3.1" +path-absolutize = "3.1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } +semver = "1.0" +sha2 = "0.10" +flate2 = "1.0" +tar = "0.4" +tempfile = "3.12" +zip = "2.2" + +[dev-dependencies] +assert_cmd = "2.0" diff --git a/examples/envlock.sample.json b/app/examples/runseal.sample.json similarity index 85% rename from examples/envlock.sample.json rename to app/examples/runseal.sample.json index 18a0f7a..3cb8c92 100644 --- a/examples/envlock.sample.json +++ b/app/examples/runseal.sample.json @@ -4,8 +4,8 @@ "type": "env", "enabled": true, "vars": { - "ENVLOCK_PROFILE": "dev", - "ENVLOCK_NODE_VERSION": "22.11.0", + "RUNSEAL_PROFILE": "dev", + "RUNSEAL_NODE_VERSION": "22.11.0", "NPM_CONFIG_REGISTRY": "https://registry.npmjs.org", "KUBECONFIG_CONTEXT": "dev-cluster", "KUBECONFIG_NAMESPACE": "platform" diff --git a/src/bin/envlock.rs b/app/src/bin/runseal.rs similarity index 89% rename from src/bin/envlock.rs rename to app/src/bin/runseal.rs index 866968b..babce4b 100644 --- a/src/bin/envlock.rs +++ b/app/src/bin/runseal.rs @@ -3,33 +3,32 @@ use std::process; use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand, ValueEnum}; -use envlock::commands::alias::{ +use runseal::commands::alias::{ AliasAppendOptions, resolve_profile_for_alias, run_append as run_alias_append, run_list as run_alias_list, }; -use envlock::commands::plugin::{PluginRunOptions, run as run_plugin}; -use envlock::commands::preview::{PreviewOutputMode, run as run_preview}; -use envlock::commands::profiles::{ +use runseal::commands::preview::{PreviewOutputMode, run as run_preview}; +use runseal::commands::profiles::{ InitProfileType, ProfilesInitOptions, run_init as run_profiles_init, run_status as run_profiles_status, }; -use envlock::commands::self_update::{SelfUpdateOptions, run as run_self_update}; -use envlock::commands::skill::{SkillInstallOptions, run_install as run_skill_install}; -use envlock::core::app::App; -use envlock::core::config::{ +use runseal::commands::self_update::{SelfUpdateOptions, run as run_self_update}; +use runseal::commands::skill::{SkillInstallOptions, run_install as run_skill_install}; +use runseal::core::app::App; +use runseal::core::config::{ CliInput, LogFormat as RuntimeLogFormat, OutputMode, RawEnv, RuntimeConfig, }; -use envlock::logging::{SessionLog, current_log_file, make_file_writer, prepare_session_log}; -use envlock::plugins::host::plugin_exit_code; -use envlock::run; +use runseal::helpers::{HelperRunOptions, helper_exit_code, run as run_helper}; +use runseal::logging::{SessionLog, current_log_file, make_file_writer, prepare_session_log}; +use runseal::run; use tracing_subscriber::{EnvFilter, prelude::*}; #[derive(Debug, Parser)] #[command( - name = "envlock", + name = "runseal", version, - about = "Build environment sessions from JSON profile", - after_help = "Docs: https://perishcode.github.io/envlock/" + about = "Seal the run.", + after_help = "Docs: https://runseal.ai/" )] struct Cli { #[command(subcommand)] @@ -46,7 +45,7 @@ enum Commands { Profiles(ProfilesArgs), Alias(AliasArgs), Skill(SkillArgs), - Plugin(PluginArgs), + Helper(HelperArgs), #[command(external_subcommand)] External(Vec), } @@ -116,10 +115,8 @@ enum SkillSubcommand { } #[derive(Debug, Args)] -struct PluginArgs { - plugin: String, - - method: String, +struct HelperArgs { + reference: String, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -194,7 +191,7 @@ fn main() -> Result<()> { cli.run_args.log_format.into(), session_log.as_ref(), )?; - tracing::info!(command = %command_slug(&cli), "envlock invocation started"); + tracing::info!(command = %command_slug(&cli), "runseal invocation started"); if let Some(command) = cli.subcommand { let result = match command { @@ -238,11 +235,8 @@ fn main() -> Result<()> { yes: install.yes, }), }, - Commands::Plugin(args) => run_plugin(PluginRunOptions { - force_install: args.method == "init" - && args.args.iter().any(|arg| arg == "--force"), - plugin: args.plugin, - method: args.method, + Commands::Helper(args) => run_helper(HelperRunOptions { + reference: args.reference, args: args.args, }), Commands::External(tokens) => run_external_command(&tokens, &cli.run_args), @@ -277,19 +271,19 @@ fn main() -> Result<()> { fn finish_command(result: Result<()>) -> Result<()> { match result { Ok(()) => { - tracing::info!(exit_code = 0, "envlock invocation completed"); + tracing::info!(exit_code = 0, "runseal invocation completed"); Ok(()) } Err(error) => { - if let Some(code) = plugin_exit_code(&error) { - tracing::error!(exit_code = code, error = %error, "envlock invocation failed"); + if let Some(code) = helper_exit_code(&error) { + tracing::error!(exit_code = code, error = %error, "runseal invocation failed"); eprintln!("{error}"); if let Some(path) = current_log_file() { eprintln!("See log: {}", path.display()); } process::exit(code); } - tracing::error!(error = %error, "envlock invocation failed"); + tracing::error!(error = %error, "runseal invocation failed"); if let Some(path) = current_log_file() { eprintln!("See log: {}", path.display()); } @@ -300,7 +294,7 @@ fn finish_command(result: Result<()>) -> Result<()> { fn run_external_command(tokens: &[String], run_args: &RunArgs) -> Result<()> { let alias_name = parse_shortcut_alias_name(tokens) - .context("unknown command. alias shortcut must use envlock :")?; + .context("unknown command. alias shortcut must use runseal :")?; let command = if tokens.len() > 1 { Some(tokens[1..].to_vec()) } else { @@ -463,7 +457,7 @@ fn init_logging( fn command_slug(cli: &Cli) -> String { match &cli.subcommand { - Some(Commands::Plugin(args)) => format!("plugin-{}-{}", args.plugin, args.method), + Some(Commands::Helper(_)) => "helper".to_owned(), Some(Commands::SelfUpdate(_)) => "self-update".to_owned(), Some(Commands::Preview(_)) => "preview".to_owned(), Some(Commands::Profiles(_)) => "profiles".to_owned(), diff --git a/src/commands/alias.rs b/app/src/commands/alias.rs similarity index 68% rename from src/commands/alias.rs rename to app/src/commands/alias.rs index 0031b61..7a64fef 100644 --- a/src/commands/alias.rs +++ b/app/src/commands/alias.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::{Context, Result, bail}; use crate::core::alias_store::AliasStore; -use crate::core::config::{RawEnv, resolve_envlock_home}; +use crate::core::config::{RawEnv, resolve_runseal_home}; pub struct AliasAppendOptions { pub name: String, @@ -11,8 +11,8 @@ pub struct AliasAppendOptions { } pub fn run_list() -> Result<()> { - let envlock_home = resolve_envlock_home(&RawEnv::from_process())?; - let store = AliasStore::load(&envlock_home)?; + let runseal_home = resolve_runseal_home(&RawEnv::from_process())?; + let store = AliasStore::load(&runseal_home)?; if store.list().next().is_none() { println!("No aliases configured."); return Ok(()); @@ -30,15 +30,15 @@ pub fn run_append(options: AliasAppendOptions) -> Result<()> { bail!("alias name cannot be empty"); } - let envlock_home = resolve_envlock_home(&RawEnv::from_process())?; - let mut store = AliasStore::load(&envlock_home)?; + let runseal_home = resolve_runseal_home(&RawEnv::from_process())?; + let mut store = AliasStore::load(&runseal_home)?; if !Path::new(&options.profile).is_file() { bail!("profile file not found: {}", options.profile); } store.append(options.name.clone(), options.profile.clone())?; - let path = store.save(&envlock_home)?; + let path = store.save(&runseal_home)?; println!( "Appended alias: {} -> {} ({})", options.name, @@ -49,8 +49,8 @@ pub fn run_append(options: AliasAppendOptions) -> Result<()> { } pub fn resolve_profile_for_alias(name: &str) -> Result> { - let envlock_home = resolve_envlock_home(&RawEnv::from_process()) - .context("unable to resolve envlock home for alias lookup")?; - let store = AliasStore::load(&envlock_home)?; + let runseal_home = resolve_runseal_home(&RawEnv::from_process()) + .context("unable to resolve runseal home for alias lookup")?; + let store = AliasStore::load(&runseal_home)?; Ok(store.get(name).map(|entry| entry.profile.clone())) } diff --git a/src/commands/mod.rs b/app/src/commands/mod.rs similarity index 84% rename from src/commands/mod.rs rename to app/src/commands/mod.rs index 52d95a6..4865717 100644 --- a/src/commands/mod.rs +++ b/app/src/commands/mod.rs @@ -1,5 +1,4 @@ pub mod alias; -pub mod plugin; pub mod preview; pub mod profiles; pub mod self_update; diff --git a/src/commands/preview.rs b/app/src/commands/preview.rs similarity index 100% rename from src/commands/preview.rs rename to app/src/commands/preview.rs diff --git a/src/commands/profiles.rs b/app/src/commands/profiles.rs similarity index 87% rename from src/commands/profiles.rs rename to app/src/commands/profiles.rs index c709399..2901130 100644 --- a/src/commands/profiles.rs +++ b/app/src/commands/profiles.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; -use crate::core::config::{RawEnv, resolve_envlock_home}; +use crate::core::config::{RawEnv, resolve_runseal_home}; pub enum InitProfileType { Minimal, @@ -16,11 +16,11 @@ pub struct ProfilesInitOptions { } pub fn run_status() -> Result<()> { - let envlock_home = resolve_envlock_home(&RawEnv::from_process())?; - let profiles_dir = envlock_home.join("profiles"); + let runseal_home = resolve_runseal_home(&RawEnv::from_process())?; + let profiles_dir = runseal_home.join("profiles"); let default_profile = profiles_dir.join("default.json"); - println!("envlock_home: {}", envlock_home.display()); + println!("runseal_home: {}", runseal_home.display()); println!("profiles_dir: {}", profiles_dir.display()); println!( "default_profile: {} ({})", @@ -66,8 +66,8 @@ pub fn run_status() -> Result<()> { } pub fn run_init(options: ProfilesInitOptions) -> Result<()> { - let envlock_home = resolve_envlock_home(&RawEnv::from_process())?; - let profiles_dir = envlock_home.join("profiles"); + let runseal_home = resolve_runseal_home(&RawEnv::from_process())?; + let profiles_dir = runseal_home.join("profiles"); std::fs::create_dir_all(&profiles_dir).with_context(|| { format!( "failed to create profiles directory: {}", @@ -112,7 +112,7 @@ fn render_profile_template(profile_type: InitProfileType) -> String { { "type": "env", "vars": { - "ENVLOCK_PROFILE": "default" + "RUNSEAL_PROFILE": "default" } } ] @@ -124,8 +124,8 @@ fn render_profile_template(profile_type: InitProfileType) -> String { { "type": "env", "vars": { - "ENVLOCK_PROFILE": "sample", - "ENVLOCK_SCOPE": "child-only" + "RUNSEAL_PROFILE": "sample", + "RUNSEAL_SCOPE": "child-only" } }, { diff --git a/src/commands/self_update.rs b/app/src/commands/self_update.rs similarity index 75% rename from src/commands/self_update.rs rename to app/src/commands/self_update.rs index 3e91abb..ee1823b 100644 --- a/src/commands/self_update.rs +++ b/app/src/commands/self_update.rs @@ -12,10 +12,10 @@ use tar::Archive; use tempfile::TempDir; const REPO_OWNER: &str = "PerishCode"; -const REPO_NAME: &str = "envlock"; -const DOCS_CHANGELOG_URL: &str = "https://perishcode.github.io/envlock/changelog"; -const DOCS_CHANGELOG_FEED_URL: &str = "https://perishcode.github.io/envlock/changelog-lite.json"; -const DOCS_SKILL_INSTALL_URL: &str = "https://perishcode.github.io/envlock/how-to/install"; +const REPO_NAME: &str = "runseal"; +const DOCS_CHANGELOG_URL: &str = "https://runseal.ai/changelog"; +const DOCS_CHANGELOG_FEED_URL: &str = "https://runseal.ai/changelog-lite.json"; +const DOCS_SKILL_INSTALL_URL: &str = "https://runseal.ai/how-to/install"; #[derive(Debug, Clone)] pub struct SelfUpdateOptions { @@ -58,7 +58,7 @@ pub fn run(options: SelfUpdateOptions) -> Result<()> { if target <= current { println!( - "envlock is up to date (current: v{}, target: {}).", + "runseal is up to date (current: v{}, target: {}).", current, release.tag_name ); return Ok(()); @@ -68,7 +68,7 @@ pub fn run(options: SelfUpdateOptions) -> Result<()> { println!("Update available: v{} -> {}", current, release.tag_name); print_release_notes(&release, docs_changelog.as_ref()); println!( - "Tip: run `envlock skill install --version {} --yes` after update to sync skills.", + "Tip: run `runseal skill install --version {} --yes` after update to sync skills.", release.tag_name ); if let Err(err) = resolve_update_target_path() { @@ -85,7 +85,7 @@ pub fn run(options: SelfUpdateOptions) -> Result<()> { resolve_update_target_path().context("unable to resolve managed install target")?; let target_triple = current_target_triple()?; - let archive_name = format!("envlock-{}-{target_triple}.tar.gz", release.tag_name); + let archive_name = format!("runseal-{}-{target_triple}.tar.gz", release.tag_name); let archive_asset = find_asset(&release.assets, &archive_name) .with_context(|| format!("release asset not found: {archive_name}"))?; let checksums_asset = find_asset(&release.assets, "checksums.txt") @@ -110,10 +110,10 @@ pub fn run(options: SelfUpdateOptions) -> Result<()> { let extracted_binary = extract_binary(&archive_bytes, temp_dir.path())?; replace_binary_at_path(extracted_binary, &target_binary)?; - println!("Updated envlock to {}", release.tag_name); + println!("Updated runseal to {}", release.tag_name); print_release_notes(&release, docs_changelog.as_ref()); println!( - "Tip: run `envlock skill install --version {} --yes` to install matching skills.", + "Tip: run `runseal skill install --version {} --yes` to install matching skills.", release.tag_name ); println!("Skill install docs: {}", DOCS_SKILL_INSTALL_URL); @@ -227,7 +227,7 @@ fn parse_semver(tag: &str) -> Result { } fn prompt_for_confirmation(current: &Version, next_tag: &str) -> Result<()> { - print!("Upgrade envlock from v{} to {}? [y/N]: ", current, next_tag); + print!("Upgrade runseal from v{} to {}? [y/N]: ", current, next_tag); io::stdout() .flush() .context("failed to flush confirmation prompt")?; @@ -293,11 +293,11 @@ fn extract_binary(archive_bytes: &[u8], temp_root: &std::path::Path) -> Result

Result<()> { @@ -313,7 +313,7 @@ fn replace_binary_at_path(new_binary: PathBuf, target: &PathBuf) -> Result<()> { .parent() .context("failed to resolve executable parent directory")?; - let staged = parent.join(format!(".envlock.new.{}", std::process::id())); + let staged = parent.join(format!(".runseal.new.{}", std::process::id())); std::fs::copy(&new_binary, &staged).context("failed to stage replacement binary")?; #[cfg(unix)] @@ -324,7 +324,7 @@ fn replace_binary_at_path(new_binary: PathBuf, target: &PathBuf) -> Result<()> { std::fs::set_permissions(&staged, perms).context("failed to set executable permissions")?; } - let backup = parent.join(format!(".envlock.old.{}", std::process::id())); + let backup = parent.join(format!(".runseal.old.{}", std::process::id())); if target.exists() { std::fs::rename(target, &backup).context("failed to rotate current binary")?; @@ -353,7 +353,7 @@ fn resolve_update_target_path() -> Result { } bail!( - "self-update only supports installs under {}. Reinstall via scripts/install.sh", + "self-update only supports installs under {}. Reinstall via scripts/manage/install.sh", managed.display() ) } @@ -364,7 +364,7 @@ fn managed_install_binary_path() -> Result { fn managed_install_binary_path_with_home(home: Option) -> Result { let home = home.context("HOME is not set; unable to resolve managed install path")?; - Ok(home.join(".envlock/bin/envlock")) + Ok(home.join(".runseal/bin/runseal")) } fn current_target_triple() -> Result<&'static str> { @@ -381,85 +381,5 @@ fn current_target_triple() -> Result<&'static str> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_semver_accepts_v_prefix() { - let v = parse_semver("v1.2.3").expect("semver should parse"); - assert_eq!(v, Version::new(1, 2, 3)); - } - - #[test] - fn parse_checksum_reads_sha256sum_format() { - let checksums = "abc123 envlock-v0.2.0-x86_64-unknown-linux-gnu.tar.gz\n"; - let v = parse_checksum(checksums, "envlock-v0.2.0-x86_64-unknown-linux-gnu.tar.gz"); - assert_eq!(v.as_deref(), Some("abc123")); - } - - #[test] - fn parse_checksum_reads_shasum_star_format() { - let checksums = "def456 *envlock-v0.2.0-x86_64-unknown-linux-gnu.tar.gz\n"; - let v = parse_checksum(checksums, "envlock-v0.2.0-x86_64-unknown-linux-gnu.tar.gz"); - assert_eq!(v.as_deref(), Some("def456")); - } - - #[test] - fn managed_install_path_is_home_scoped() { - let path = managed_install_binary_path_with_home(Some(PathBuf::from("/tmp/envlock-home"))) - .expect("managed path should build"); - assert_eq!( - path, - PathBuf::from("/tmp/envlock-home/.envlock/bin/envlock") - ); - } - - #[test] - fn normalize_release_tag_adds_prefix_once() { - assert_eq!(normalize_release_tag("0.2.1"), "v0.2.1"); - assert_eq!(normalize_release_tag("v0.2.1"), "v0.2.1"); - } - - #[test] - fn release_metadata_url_uses_expected_endpoint() { - assert_eq!( - release_metadata_url(None), - "https://api.github.com/repos/PerishCode/envlock/releases/latest" - ); - assert_eq!( - release_metadata_url(Some("0.2.1")), - "https://api.github.com/repos/PerishCode/envlock/releases/tags/v0.2.1" - ); - } - - #[test] - fn release_highlights_extracts_light_changelog_lines() { - let body = "# Release v0.3.0\n\n- add meta-first docs\n- tighten converge checks\n\nSee details below."; - let items = release_highlights(Some(body), 3); - assert_eq!( - items, - vec![ - "add meta-first docs".to_string(), - "tighten converge checks".to_string(), - "See details below.".to_string() - ] - ); - } - - #[test] - fn release_highlights_handles_missing_body() { - let items = release_highlights(None, 3); - assert!(items.is_empty()); - } - - #[test] - fn docs_changelog_tag_match_normalizes_prefix() { - let release = DocsChangelogRelease { - tag: "0.2.1".to_string(), - highlights: vec!["line".to_string()], - }; - - let matched = normalize_release_tag(&release.tag) == normalize_release_tag("v0.2.1"); - assert!(matched); - } -} +#[path = "../../tests/unit/commands/self_update.rs"] +mod tests; diff --git a/src/commands/skill.rs b/app/src/commands/skill.rs similarity index 89% rename from src/commands/skill.rs rename to app/src/commands/skill.rs index 89c67df..4d9fc34 100644 --- a/src/commands/skill.rs +++ b/app/src/commands/skill.rs @@ -8,11 +8,11 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; use zip::ZipArchive; -use crate::core::config::{RawEnv, resolve_envlock_home}; +use crate::core::config::{RawEnv, resolve_runseal_home}; const REPO_OWNER: &str = "PerishCode"; -const REPO_NAME: &str = "envlock"; -pub const SKILL_INSTALL_HOME_ENV: &str = "ENVLOCK_SKILL_INSTALL_HOME"; +const REPO_NAME: &str = "runseal"; +pub const SKILL_INSTALL_HOME_ENV: &str = "RUNSEAL_SKILL_INSTALL_HOME"; #[derive(Debug, Clone)] pub struct SkillInstallOptions { @@ -60,7 +60,7 @@ pub fn run_install(options: SkillInstallOptions) -> Result<()> { } let install_home = resolve_skill_install_home()?; - let target_skill_dir = install_home.join("envlock"); + let target_skill_dir = install_home.join("runseal"); if target_skill_dir.exists() { if !options.force { @@ -112,8 +112,8 @@ fn resolve_skill_install_home() -> Result { return Ok(path); } - let envlock_home = resolve_envlock_home(&RawEnv::from_process())?; - Ok(envlock_home.join("skills")) + let runseal_home = resolve_runseal_home(&RawEnv::from_process())?; + Ok(runseal_home.join("skills")) } fn extract_skill_zip(zip_bytes: &[u8], destination: &std::path::Path) -> Result<()> { @@ -262,25 +262,5 @@ fn sha256_hex(bytes: &[u8]) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_release_tag_adds_prefix_once() { - assert_eq!(normalize_release_tag("0.4.0"), "v0.4.0"); - assert_eq!(normalize_release_tag("v0.4.0"), "v0.4.0"); - } - - #[test] - fn parse_checksum_reads_standard_and_star_format() { - let checksums = "abc123 skill-v0.4.0.zip\ndef456 *skill-v0.5.0.zip\n"; - assert_eq!( - parse_checksum(checksums, "skill-v0.4.0.zip").as_deref(), - Some("abc123") - ); - assert_eq!( - parse_checksum(checksums, "skill-v0.5.0.zip").as_deref(), - Some("def456") - ); - } -} +#[path = "../../tests/unit/commands/skill.rs"] +mod tests; diff --git a/src/core/alias_store.rs b/app/src/core/alias_store.rs similarity index 59% rename from src/core/alias_store.rs rename to app/src/core/alias_store.rs index f13a569..e02587a 100644 --- a/src/core/alias_store.rs +++ b/app/src/core/alias_store.rs @@ -24,8 +24,8 @@ struct AliasStoreFile { } impl AliasStore { - pub fn load(envlock_home: &Path) -> Result { - let path = alias_file_path(envlock_home); + pub fn load(runseal_home: &Path) -> Result { + let path = alias_file_path(runseal_home); if !path.exists() { return Ok(Self::default()); } @@ -63,15 +63,15 @@ impl AliasStore { Ok(()) } - pub fn save(&self, envlock_home: &Path) -> Result { - std::fs::create_dir_all(envlock_home).with_context(|| { + pub fn save(&self, runseal_home: &Path) -> Result { + std::fs::create_dir_all(runseal_home).with_context(|| { format!( - "failed to create envlock home directory: {}", - envlock_home.display() + "failed to create runseal home directory: {}", + runseal_home.display() ) })?; - let path = alias_file_path(envlock_home); + let path = alias_file_path(runseal_home); let payload = AliasStoreFile { version: ALIAS_STORE_VERSION, aliases: self.aliases.clone(), @@ -88,41 +88,10 @@ impl AliasStore { } } -pub fn alias_file_path(envlock_home: &Path) -> PathBuf { - envlock_home.join(ALIAS_FILE_NAME) +pub fn alias_file_path(runseal_home: &Path) -> PathBuf { + runseal_home.join(ALIAS_FILE_NAME) } #[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn append_and_persist_alias() { - let temp = TempDir::new().expect("temp dir should be created"); - let mut store = AliasStore::default(); - store - .append("work".to_string(), "profiles/work.json".to_string()) - .expect("append should succeed"); - let path = store.save(temp.path()).expect("save should succeed"); - assert!(path.exists()); - - let loaded = AliasStore::load(temp.path()).expect("load should succeed"); - assert_eq!( - loaded.get("work").map(|entry| entry.profile.as_str()), - Some("profiles/work.json") - ); - } - - #[test] - fn append_rejects_duplicate_name() { - let mut store = AliasStore::default(); - store - .append("work".to_string(), "profiles/work.json".to_string()) - .expect("first append should succeed"); - let err = store - .append("work".to_string(), "profiles/other.json".to_string()) - .expect_err("duplicate append should fail"); - assert!(err.to_string().contains("alias already exists")); - } -} +#[path = "../../tests/unit/core/alias_store.rs"] +mod tests; diff --git a/src/core/app.rs b/app/src/core/app.rs similarity index 100% rename from src/core/app.rs rename to app/src/core/app.rs diff --git a/app/src/core/config.rs b/app/src/core/config.rs new file mode 100644 index 0000000..6ac32cd --- /dev/null +++ b/app/src/core/config.rs @@ -0,0 +1,121 @@ +use std::path::PathBuf; + +use anyhow::{Result, bail}; +use tracing_subscriber::filter::LevelFilter; + +#[derive(Debug, Clone, Copy)] +pub enum OutputMode { + Shell, + Json, +} + +#[derive(Debug, Clone, Copy)] +pub enum LogFormat { + Text, + Json, +} + +#[derive(Debug, Clone)] +pub struct CliInput { + pub profile: Option, + pub output_mode: OutputMode, + pub strict: bool, + pub log_level: LevelFilter, + pub log_format: LogFormat, + pub command: Vec, +} + +#[derive(Debug, Clone)] +pub struct RawEnv { + pub home: Option, + pub runseal_home: Option, + pub runseal_resource_home: Option, +} + +impl RawEnv { + pub fn from_process() -> Self { + Self { + home: std::env::var_os("HOME") + .map(PathBuf::from) + .filter(non_empty_path), + runseal_home: std::env::var_os("RUNSEAL_HOME") + .map(PathBuf::from) + .filter(non_empty_path), + runseal_resource_home: std::env::var_os("RUNSEAL_RESOURCE_HOME") + .map(PathBuf::from) + .filter(non_empty_path), + } + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeConfig { + pub profile_path: PathBuf, + pub output_mode: OutputMode, + pub strict: bool, + pub log_level: LevelFilter, + pub log_format: LogFormat, + pub command: Option>, + pub runseal_home: PathBuf, + pub resource_home: PathBuf, +} + +impl RuntimeConfig { + pub fn from_cli_and_env(cli: CliInput, env: RawEnv) -> Result { + let runseal_home = resolve_runseal_home(&env)?; + let resource_home = env + .runseal_resource_home + .filter(non_empty_path) + .unwrap_or_else(|| runseal_home.join("resources")); + + let profile_path = if let Some(profile) = cli.profile { + profile + } else { + runseal_home.join("profiles/default.json") + }; + + if !profile_path.is_file() { + bail!( + "profile file not found: {}. create default profile at {}/profiles/default.json or pass --profile", + profile_path.display(), + runseal_home.display() + ); + } + + Ok(Self { + profile_path, + output_mode: cli.output_mode, + strict: cli.strict, + log_level: cli.log_level, + log_format: cli.log_format, + command: if cli.command.is_empty() { + None + } else { + Some(cli.command) + }, + runseal_home, + resource_home, + }) + } +} + +pub fn resolve_runseal_home(env: &RawEnv) -> Result { + env.runseal_home + .clone() + .filter(non_empty_path) + .or_else(|| { + env.home + .clone() + .filter(non_empty_path) + .map(|home| home.join(".runseal")) + }) + .ok_or_else(|| anyhow::anyhow!("HOME is not set; pass --profile or set RUNSEAL_HOME")) +} + +fn non_empty_path(path: &PathBuf) -> bool { + !path.as_os_str().is_empty() +} + +#[cfg(test)] +#[path = "../../tests/unit/core/config.rs"] +mod tests; diff --git a/src/core/env_key.rs b/app/src/core/env_key.rs similarity index 100% rename from src/core/env_key.rs rename to app/src/core/env_key.rs diff --git a/src/core/injections/command.rs b/app/src/core/injections/command.rs similarity index 73% rename from src/core/injections/command.rs rename to app/src/core/injections/command.rs index 1c07d08..7ba04b0 100644 --- a/src/core/injections/command.rs +++ b/app/src/core/injections/command.rs @@ -177,66 +177,5 @@ fn expand_vars(input: &str, env: &dyn EnvReader) -> String { } #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::*; - - struct MockEnv { - vars: BTreeMap, - } - - impl EnvReader for MockEnv { - fn var(&self, key: &str) -> Option { - self.vars.get(key).cloned() - } - } - - #[test] - fn parse_export_and_plain_assignment() { - let env = MockEnv { - vars: BTreeMap::new(), - }; - let vars = parse_exports("export A='1'\nB=2\nignored line\n", &env); - assert_eq!( - vars, - vec![ - ("A".to_string(), "1".to_string()), - ("B".to_string(), "2".to_string()) - ] - ); - } - - #[test] - fn parse_fnm_style_path_value() { - let env = MockEnv { - vars: BTreeMap::from([("ENVLOCK_TEST_PATH".to_string(), "/usr/bin:/bin".to_string())]), - }; - let vars = parse_exports( - "export PATH=\"/tmp/fnm/bin\":\"$ENVLOCK_TEST_PATH\"\n", - &env, - ); - assert_eq!( - vars, - vec![("PATH".to_string(), "/tmp/fnm/bin:/usr/bin:/bin".to_string())] - ); - } - - #[test] - fn preserve_inner_quotes_when_normalizing() { - let env = MockEnv { - vars: BTreeMap::new(), - }; - let vars = parse_exports("export A='x\"y\"z'\n", &env); - assert_eq!(vars, vec![("A".to_string(), "x\"y\"z".to_string())]); - } - - #[test] - fn skip_invalid_env_keys_from_command_output() { - let env = MockEnv { - vars: BTreeMap::new(), - }; - let vars = parse_exports("export BAD-KEY=1\nexport _GOOD=2\n", &env); - assert_eq!(vars, vec![("_GOOD".to_string(), "2".to_string())]); - } -} +#[path = "../../../tests/unit/core/injections/command.rs"] +mod tests; diff --git a/src/core/injections/env.rs b/app/src/core/injections/env.rs similarity index 55% rename from src/core/injections/env.rs rename to app/src/core/injections/env.rs index d013c36..3ed94b2 100644 --- a/src/core/injections/env.rs +++ b/app/src/core/injections/env.rs @@ -270,187 +270,5 @@ fn is_resource_token_delimiter(c: char) -> bool { } #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::path::PathBuf; - - use super::*; - use crate::core::app::{AppContext, CommandRunner, EnvReader}; - use crate::core::config::{LogFormat, OutputMode, RuntimeConfig}; - use tracing_subscriber::filter::LevelFilter; - - struct TestEnv { - vars: BTreeMap, - } - - impl EnvReader for TestEnv { - fn var(&self, key: &str) -> Option { - self.vars.get(key).cloned() - } - } - - struct TestRunner; - - impl CommandRunner for TestRunner { - fn output(&self, program: &str, args: &[String]) -> Result { - std::process::Command::new(program) - .args(args) - .output() - .map_err(Into::into) - } - } - - struct TestApp { - cfg: RuntimeConfig, - env: TestEnv, - runner: TestRunner, - } - - impl TestApp { - fn new(resource_home: &str, vars: BTreeMap) -> Self { - Self { - cfg: RuntimeConfig { - profile_path: PathBuf::from("/tmp/unused.json"), - output_mode: OutputMode::Shell, - strict: false, - log_level: LevelFilter::WARN, - log_format: LogFormat::Text, - command: None, - envlock_home: PathBuf::from("/tmp/envlock-home"), - resource_home: PathBuf::from(resource_home), - }, - env: TestEnv { vars }, - runner: TestRunner, - } - } - } - - impl AppContext for TestApp { - fn config(&self) -> &RuntimeConfig { - &self.cfg - } - - fn env(&self) -> &dyn EnvReader { - &self.env - } - - fn command_runner(&self) -> &dyn CommandRunner { - &self.runner - } - } - - #[test] - fn rejects_empty_env_key() { - let mut vars = BTreeMap::new(); - vars.insert(" ".to_string(), "x".to_string()); - let injection = EnvInjection::new(EnvProfile { - enabled: true, - vars, - ops: Vec::new(), - }); - let err = injection.validate().expect_err("empty key should fail"); - assert!(err.to_string().contains("env var key must not be empty")); - } - - #[test] - fn prepend_path_with_dedup() { - let mut vars = BTreeMap::new(); - vars.insert("PATH".to_string(), "/usr/bin:/bin".to_string()); - let injection = EnvInjection::new(EnvProfile { - enabled: true, - vars, - ops: vec![EnvOpProfile::Prepend { - key: "PATH".to_string(), - value: "/custom/bin:/usr/bin".to_string(), - separator: Some("os".to_string()), - dedup: true, - }], - }); - let app = TestApp::new("/tmp/envlock-res", BTreeMap::new()); - - let exports = injection.export(&app).expect("export should pass"); - let path = exports - .into_iter() - .find(|(k, _)| k == "PATH") - .map(|(_, v)| v) - .expect("PATH should exist"); - assert_eq!(path, "/custom/bin:/usr/bin:/bin"); - } - - #[test] - fn set_if_absent_uses_current_env() { - let key = "ENVLOCK_TEST_SET_IF_ABSENT"; - let app = TestApp::new( - "/tmp/envlock-res", - BTreeMap::from([(key.to_string(), "present".to_string())]), - ); - let injection = EnvInjection::new(EnvProfile { - enabled: true, - vars: BTreeMap::new(), - ops: vec![EnvOpProfile::SetIfAbsent { - key: key.to_string(), - value: "fallback".to_string(), - }], - }); - let exports = injection.export(&app).expect("export should pass"); - assert!(!exports.iter().any(|(k, _)| k == key)); - } - - #[test] - fn resolves_resource_uri_with_default_home() { - let resolved = resolve_resource_refs( - "resource://kubeconfig/xx.yaml", - std::path::Path::new("/tmp/envlock-res"), - ) - .expect("resource path should resolve"); - assert_eq!(resolved, "/tmp/envlock-res/kubeconfig/xx.yaml"); - } - - #[test] - fn resolves_multiple_resource_uris_in_one_value() { - let resolved = resolve_resource_refs( - "resource://kubeconfig/xx.yaml:resource://kubeconfig/yy.yaml", - std::path::Path::new("/tmp/envlock-res"), - ) - .expect("multiple resource paths should resolve"); - assert_eq!( - resolved, - "/tmp/envlock-res/kubeconfig/xx.yaml:/tmp/envlock-res/kubeconfig/yy.yaml" - ); - } - - #[test] - fn resolves_resource_content_uri() { - let temp = tempfile::tempdir().expect("temp dir should exist"); - let dir = temp.path().join("opencode"); - std::fs::create_dir_all(&dir).expect("resource dir should exist"); - let cfg = dir.join("alpha.json"); - std::fs::write(&cfg, "{\"default_agent\":\"alpha\"}") - .expect("resource content should be written"); - - let resolved = resolve_resource_refs("resource-content://opencode/alpha.json", temp.path()) - .expect("resource content should resolve"); - assert_eq!(resolved, "{\"default_agent\":\"alpha\"}"); - } - - #[test] - fn resolves_resource_content_followed_by_separator() { - let temp = tempfile::tempdir().expect("temp dir should exist"); - std::fs::write(temp.path().join("token.txt"), "ALPHA_ONLY") - .expect("resource content should be written"); - - let resolved = resolve_resource_refs("A=resource-content://token.txt;B=1", temp.path()) - .expect("resource content with separator should resolve"); - assert_eq!(resolved, "A=ALPHA_ONLY;B=1"); - } - - #[test] - fn errors_when_resource_content_missing() { - let err = resolve_resource_refs( - "resource-content://missing.json", - std::path::Path::new("/tmp/envlock-res"), - ) - .expect_err("missing content file should fail"); - assert!(err.to_string().contains("failed to read resource content")); - } -} +#[path = "../../../tests/unit/core/injections/env.rs"] +mod tests; diff --git a/src/core/injections/mod.rs b/app/src/core/injections/mod.rs similarity index 50% rename from src/core/injections/mod.rs rename to app/src/core/injections/mod.rs index 7529d8b..1d2ae5e 100644 --- a/src/core/injections/mod.rs +++ b/app/src/core/injections/mod.rs @@ -219,185 +219,5 @@ impl RuntimeInjection { } #[cfg(test)] -mod tests { - use super::*; - use crate::core::app::{AppContext, CommandRunner, EnvReader}; - use crate::core::config::{LogFormat, OutputMode, RuntimeConfig}; - use std::collections::BTreeMap; - use std::path::PathBuf; - use tempfile::TempDir; - use tracing_subscriber::filter::LevelFilter; - - struct TestEnv; - - impl EnvReader for TestEnv { - fn var(&self, _key: &str) -> Option { - None - } - } - - struct TestRunner; - - impl CommandRunner for TestRunner { - fn output(&self, program: &str, args: &[String]) -> Result { - std::process::Command::new(program) - .args(args) - .output() - .map_err(Into::into) - } - } - - struct TestApp { - cfg: RuntimeConfig, - env: TestEnv, - runner: TestRunner, - } - - impl TestApp { - fn new() -> Self { - Self { - cfg: RuntimeConfig { - profile_path: PathBuf::from("/tmp/unused.json"), - output_mode: OutputMode::Shell, - strict: false, - log_level: LevelFilter::WARN, - log_format: LogFormat::Text, - command: None, - envlock_home: PathBuf::from("/tmp/envlock-home"), - resource_home: PathBuf::from("/tmp/envlock-res"), - }, - env: TestEnv, - runner: TestRunner, - } - } - } - - impl AppContext for TestApp { - fn config(&self) -> &RuntimeConfig { - &self.cfg - } - - fn env(&self) -> &dyn EnvReader { - &self.env - } - - fn command_runner(&self) -> &dyn CommandRunner { - &self.runner - } - } - - #[test] - fn skip_disabled_env_injection() { - let specs = vec![ - InjectionProfile::Env(crate::core::profile::EnvProfile { - enabled: false, - vars: BTreeMap::from([("A".to_string(), "1".to_string())]), - ops: Vec::new(), - }), - InjectionProfile::Env(crate::core::profile::EnvProfile { - enabled: true, - vars: BTreeMap::from([("B".to_string(), "2".to_string())]), - ops: Vec::new(), - }), - ]; - - let app = TestApp::new(); - let exports = execute_lifecycle(&app, specs).expect("lifecycle should pass"); - assert_eq!(exports.len(), 1); - assert!(exports.contains(&("B".to_string(), "2".to_string()))); - } - - #[test] - fn fail_validation_when_env_key_is_empty() { - let specs = vec![InjectionProfile::Env(crate::core::profile::EnvProfile { - enabled: true, - vars: BTreeMap::from([(" ".to_string(), "1".to_string())]), - ops: Vec::new(), - })]; - - let app = TestApp::new(); - let err = execute_lifecycle(&app, specs).expect_err("empty env key should fail"); - assert!(err.to_string().contains("validation failed")); - } - - #[test] - fn command_injection_exports_values() { - let specs = vec![InjectionProfile::Command( - crate::core::profile::CommandProfile { - enabled: true, - program: "bash".to_string(), - args: vec![ - "-lc".to_string(), - "printf \"export CMD_A='1'\\nCMD_B=2\\n\"".to_string(), - ], - }, - )]; - - let app = TestApp::new(); - let exports = execute_lifecycle(&app, specs).expect("command lifecycle should pass"); - assert!(exports.contains(&("CMD_A".to_string(), "1".to_string()))); - assert!(exports.contains(&("CMD_B".to_string(), "2".to_string()))); - } - - #[test] - fn command_injection_observes_prior_exports() { - let specs = vec![ - InjectionProfile::Env(crate::core::profile::EnvProfile { - enabled: true, - vars: BTreeMap::from([("BASE".to_string(), "seed".to_string())]), - ops: Vec::new(), - }), - InjectionProfile::Command(crate::core::profile::CommandProfile { - enabled: true, - program: "bash".to_string(), - args: vec![ - "-lc".to_string(), - "printf 'export DERIVED=${BASE}-ok\\n'".to_string(), - ], - }), - ]; - - let app = TestApp::new(); - let exports = execute_lifecycle(&app, specs).expect("command should see prior exports"); - assert!(exports.contains(&("BASE".to_string(), "seed".to_string()))); - assert!(exports.contains(&("DERIVED".to_string(), "seed-ok".to_string()))); - } - - #[test] - fn register_failure_rolls_back_prior_registered_injections() { - let temp = TempDir::new().expect("temp dir should be created"); - let source_a = temp.path().join("source-a"); - let source_b = temp.path().join("source-b"); - let target_a = temp.path().join("target-a"); - let target_b = temp.path().join("target-b"); - - std::fs::write(&source_a, "a").expect("source-a should exist"); - std::fs::write(&source_b, "b").expect("source-b should exist"); - std::fs::write(&target_b, "occupied").expect("target-b should exist"); - - let specs = vec![ - InjectionProfile::Symlink(crate::core::profile::SymlinkProfile { - enabled: true, - source: source_a.clone(), - target: target_a.clone(), - on_exist: crate::core::profile::SymlinkOnExist::Error, - cleanup: true, - }), - InjectionProfile::Symlink(crate::core::profile::SymlinkProfile { - enabled: true, - source: source_b, - target: target_b, - on_exist: crate::core::profile::SymlinkOnExist::Error, - cleanup: true, - }), - ]; - - let app = TestApp::new(); - let err = execute_lifecycle(&app, specs).expect_err("second register should fail"); - assert!(err.to_string().contains("registration failed")); - assert!( - std::fs::symlink_metadata(&target_a).is_err(), - "first symlink should be rolled back on later register failure" - ); - } -} +#[path = "../../../tests/unit/core/injections/mod.rs"] +mod tests; diff --git a/src/core/injections/symlink.rs b/app/src/core/injections/symlink.rs similarity index 50% rename from src/core/injections/symlink.rs rename to app/src/core/injections/symlink.rs index 2bc0c82..5361260 100644 --- a/src/core/injections/symlink.rs +++ b/app/src/core/injections/symlink.rs @@ -94,84 +94,5 @@ impl SymlinkInjection { } #[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn register_fails_when_target_exists_in_error_mode() { - let temp = TempDir::new().expect("temp dir should be created"); - let source = temp.path().join("source.md"); - let target = temp.path().join("AGENTS.md"); - std::fs::write(&source, "content").expect("source file should be created"); - std::fs::write(&target, "existing").expect("target file should be created"); - - let injection = SymlinkInjection::new(SymlinkProfile { - enabled: true, - source: source.clone(), - target: target.clone(), - on_exist: SymlinkOnExist::Error, - cleanup: true, - }); - - let err = injection - .register_at(&source, &target, SymlinkOnExist::Error) - .expect_err("existing target should fail"); - assert!( - err.to_string() - .contains("refusing to overwrite existing file") - ); - } - - #[test] - fn register_and_shutdown_manage_symlink() { - let temp = TempDir::new().expect("temp dir should be created"); - let source = temp.path().join("source.md"); - let target = temp.path().join(".codex/AGENTS.md"); - std::fs::write(&source, "content").expect("source file should be created"); - - let mut injection = SymlinkInjection::new(SymlinkProfile { - enabled: true, - source: source.clone(), - target: target.clone(), - on_exist: SymlinkOnExist::Error, - cleanup: true, - }); - - injection - .register() - .expect("register should create symlink"); - - let metadata = std::fs::symlink_metadata(&target).expect("symlink should exist"); - assert!(metadata.file_type().is_symlink()); - - injection - .shutdown() - .expect("shutdown should remove symlink"); - assert!( - std::fs::symlink_metadata(&target).is_err(), - "symlink should be removed" - ); - } - - #[test] - fn replace_mode_replaces_existing_file() { - let temp = TempDir::new().expect("temp dir should be created"); - let source = temp.path().join("source.md"); - let target = temp.path().join("AGENTS.md"); - std::fs::write(&source, "content").expect("source file should be created"); - std::fs::write(&target, "existing").expect("target file should be created"); - - let mut injection = SymlinkInjection::new(SymlinkProfile { - enabled: true, - source: source.clone(), - target: target.clone(), - on_exist: SymlinkOnExist::Replace, - cleanup: true, - }); - - injection.register().expect("replace mode should succeed"); - let metadata = std::fs::symlink_metadata(&target).expect("target should exist"); - assert!(metadata.file_type().is_symlink()); - } -} +#[path = "../../../tests/unit/core/injections/symlink.rs"] +mod tests; diff --git a/src/core/mod.rs b/app/src/core/mod.rs similarity index 100% rename from src/core/mod.rs rename to app/src/core/mod.rs diff --git a/src/core/profile.rs b/app/src/core/profile.rs similarity index 54% rename from src/core/profile.rs rename to app/src/core/profile.rs index db09047..61c7b74 100644 --- a/src/core/profile.rs +++ b/app/src/core/profile.rs @@ -143,98 +143,5 @@ fn normalize_path(path: &Path, base_dir: &Path) -> Result { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_injections_with_defaults() { - let raw = r#" - { - "injections": [ - { "type": "env", "vars": { "A": "1", "B": "2" } }, - { "type": "symlink", "source": "./fixtures/agents.md", "target": "~/.codex/AGENTS.md" } - ] - }"#; - - let profile: Profile = serde_json::from_str(raw).expect("profile should parse"); - assert_eq!(profile.injections.len(), 2); - - match &profile.injections[0] { - InjectionProfile::Env(env) => { - assert!(env.enabled); - assert_eq!(env.vars.get("A"), Some(&"1".to_string())); - assert_eq!(env.vars.get("B"), Some(&"2".to_string())); - assert!(env.ops.is_empty()); - } - _ => panic!("expected env injection"), - } - match &profile.injections[1] { - InjectionProfile::Symlink(link) => { - assert!(link.enabled); - assert!(matches!(link.on_exist, SymlinkOnExist::Error)); - assert!(link.cleanup); - } - _ => panic!("expected symlink injection"), - } - } - - #[test] - fn reject_unknown_injection_type() { - let raw = r#" - { - "injections": [ - { "type": "python" } - ] - }"#; - - let err = serde_json::from_str::(raw).expect_err("unknown type should fail"); - let msg = err.to_string(); - assert!(msg.contains("unknown variant")); - } - - #[test] - fn parse_env_ops() { - let raw = r#" - { - "injections": [ - { - "type": "env", - "vars": { "A": "1" }, - "ops": [ - { "op": "prepend", "key": "PATH", "value": "/opt/bin", "separator": "os", "dedup": true }, - { "op": "set_if_absent", "key": "NPM_CONFIG_REGISTRY", "value": "https://registry.npmjs.org/" } - ] - } - ] - }"#; - - let profile: Profile = serde_json::from_str(raw).expect("profile should parse"); - match &profile.injections[0] { - InjectionProfile::Env(env) => { - assert_eq!(env.vars.get("A"), Some(&"1".to_string())); - assert_eq!(env.ops.len(), 2); - } - _ => panic!("expected env injection"), - } - } - - #[test] - fn parse_command_injection() { - let raw = r#" - { - "injections": [ - { "type": "command", "program": "fnm", "args": ["env", "--shell", "bash"] } - ] - }"#; - - let profile: Profile = serde_json::from_str(raw).expect("profile should parse"); - match &profile.injections[0] { - InjectionProfile::Command(cmd) => { - assert!(cmd.enabled); - assert_eq!(cmd.program, "fnm"); - assert_eq!(cmd.args, vec!["env", "--shell", "bash"]); - } - _ => panic!("expected command injection"), - } - } -} +#[path = "../../tests/unit/core/profile.rs"] +mod tests; diff --git a/src/core/runtime.rs b/app/src/core/runtime.rs similarity index 69% rename from src/core/runtime.rs rename to app/src/core/runtime.rs index d0bdc37..b8402f9 100644 --- a/src/core/runtime.rs +++ b/app/src/core/runtime.rs @@ -22,9 +22,9 @@ pub fn run(app: &dyn AppContext) -> Result { }, strict = config.strict, has_command = config.command.is_some(), - "envlock run started" + "runseal run started" ); - let profile = profile::load(&config.profile_path).context("unable to load envlock profile")?; + let profile = profile::load(&config.profile_path).context("unable to load runseal profile")?; let run_result = injections::with_registered_exports(app, profile.injections, |exports| { info!( export_count = exports.len(), @@ -41,7 +41,7 @@ pub fn run(app: &dyn AppContext) -> Result { print_outputs(env, config.output_mode)?; Ok(RunResult { exit_code: None }) })?; - info!("envlock run completed"); + info!("runseal run completed"); Ok(run_result) } @@ -110,46 +110,5 @@ fn run_command(command: &[String], exports: &[(String, String)]) -> Result } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn escape_single_quotes_for_shell() { - assert_eq!(shell_single_quote_escape("a'b"), "a'\"'\"'b"); - } - - #[test] - fn env_map_keeps_last_value_for_duplicate_keys() { - let map = to_env_map( - vec![ - ("A".to_string(), "1".to_string()), - ("B".to_string(), "2".to_string()), - ("A".to_string(), "3".to_string()), - ], - false, - ) - .expect("non-strict mode should allow duplicate keys"); - assert_eq!(map.get("A"), Some(&"3".to_string())); - assert_eq!(map.get("B"), Some(&"2".to_string())); - } - - #[test] - fn env_map_rejects_duplicate_keys_in_strict_mode() { - let err = to_env_map( - vec![ - ("A".to_string(), "1".to_string()), - ("A".to_string(), "2".to_string()), - ], - true, - ) - .expect_err("strict mode should reject duplicate keys"); - assert!(err.to_string().contains("duplicate exported key")); - } - - #[test] - fn env_map_rejects_invalid_key() { - let err = to_env_map(vec![("BAD-KEY".to_string(), "1".to_string())], false) - .expect_err("invalid env key should fail"); - assert!(err.to_string().contains("invalid exported key")); - } -} +#[path = "../../tests/unit/core/runtime.rs"] +mod tests; diff --git a/app/src/helpers/mod.rs b/app/src/helpers/mod.rs new file mode 100644 index 0000000..991c66e --- /dev/null +++ b/app/src/helpers/mod.rs @@ -0,0 +1,248 @@ +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, anyhow, bail}; +use reqwest::blocking::Client; + +use crate::core::config::{RawEnv, resolve_runseal_home}; +use crate::logging::current_log_file; + +pub const HELPER_ALIAS_TEMPLATE_ENV: &str = "RUNSEAL_HELPER_ALIAS_TEMPLATE"; + +#[derive(Debug, Clone)] +pub struct HelperRunOptions { + pub reference: String, + pub args: Vec, +} + +#[derive(Debug)] +pub struct HelperCommandError { + exit_code: i32, + reference: String, +} + +impl HelperCommandError { + pub fn exit_code(&self) -> i32 { + self.exit_code + } +} + +impl std::fmt::Display for HelperCommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "helper `{}` failed with exit code {}", + self.reference, self.exit_code + ) + } +} + +impl std::error::Error for HelperCommandError {} + +pub fn helper_exit_code(error: &anyhow::Error) -> Option { + error + .downcast_ref::() + .map(HelperCommandError::exit_code) +} + +pub fn run(options: HelperRunOptions) -> Result<()> { + let raw_env = RawEnv::from_process(); + let runseal_home = resolve_runseal_home(&raw_env)?; + let resolved = resolve_reference(&options.reference)?; + let script_path = materialize_script(&resolved, &runseal_home)?; + + tracing::info!( + helper_ref = %options.reference, + script = %script_path.display(), + "helper execution starting" + ); + + let mut command = Command::new("bash"); + command.arg(&script_path).args(&options.args); + command.env("RUNSEAL_HOME", &runseal_home); + command.env("RUNSEAL_HELPER_REF", &options.reference); + if let Some(alias) = resolved.alias_name() { + command.env("RUNSEAL_HELPER_ALIAS", alias); + } + if let Some(path) = current_log_file() { + command.env("RUNSEAL_LOG_FILE", path); + } + + let output = command + .output() + .with_context(|| format!("failed to execute helper script: {}", script_path.display()))?; + + io::stdout() + .write_all(&output.stdout) + .context("failed to write helper stdout")?; + io::stderr() + .write_all(&output.stderr) + .context("failed to write helper stderr")?; + + if !output.status.success() { + let code = output.status.code().unwrap_or(1); + tracing::warn!(helper_ref = %options.reference, exit_code = code, "helper execution failed"); + return Err(anyhow!(HelperCommandError { + exit_code: code, + reference: options.reference, + })); + } + + Ok(()) +} + +#[derive(Debug, Clone)] +enum ResolvedHelperReference { + LocalPath(PathBuf), + RemoteUrl { + url: String, + cache_path: Option, + }, +} + +impl ResolvedHelperReference { + fn alias_name(&self) -> Option<&str> { + match self { + Self::RemoteUrl { cache_path, .. } => cache_path + .as_ref() + .and_then(|path| path.file_stem()) + .and_then(|name| name.to_str()), + Self::LocalPath(_) => None, + } + } +} + +fn resolve_reference(reference: &str) -> Result { + if let Some(alias) = reference.strip_prefix(':') { + validate_alias(alias)?; + return resolve_alias(alias); + } + + if is_http_url(reference) { + return Ok(ResolvedHelperReference::RemoteUrl { + url: reference.to_owned(), + cache_path: None, + }); + } + + Ok(ResolvedHelperReference::LocalPath(PathBuf::from(reference))) +} + +fn resolve_alias(alias: &str) -> Result { + let template = std::env::var(HELPER_ALIAS_TEMPLATE_ENV).with_context(|| { + format!( + "{} is required to resolve helper alias :{}", + HELPER_ALIAS_TEMPLATE_ENV, alias + ) + })?; + let version = format!("v{}", env!("CARGO_PKG_VERSION")); + let rendered = render_alias_template(&template, alias, &version); + + if is_http_url(&rendered) { + return Ok(ResolvedHelperReference::RemoteUrl { + url: rendered, + cache_path: Some( + PathBuf::from("helpers") + .join(&version) + .join(format!("{alias}.sh")), + ), + }); + } + + Ok(ResolvedHelperReference::LocalPath(PathBuf::from(rendered))) +} + +fn materialize_script(reference: &ResolvedHelperReference, runseal_home: &Path) -> Result { + match reference { + ResolvedHelperReference::LocalPath(path) => { + if !path.is_file() { + bail!("helper script not found: {}", path.display()); + } + Ok(path.clone()) + } + ResolvedHelperReference::RemoteUrl { url, cache_path } => { + let bytes = download_bytes(url)?; + let cache_relative = cache_path + .clone() + .unwrap_or_else(|| PathBuf::from("helpers/adhoc/remote.sh")); + let full = runseal_home.join(cache_relative); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create helper cache directory: {}", + parent.display() + ) + })?; + } + std::fs::write(&full, bytes).with_context(|| { + format!("failed to write helper cache file: {}", full.display()) + })?; + set_executable(&full)?; + Ok(full) + } + } +} + +fn download_bytes(url: &str) -> Result> { + http_client()? + .get(url) + .send() + .with_context(|| format!("failed to fetch helper script: {url}"))? + .error_for_status() + .with_context(|| format!("helper download request failed: {url}"))? + .bytes() + .context("failed to read helper script response body") + .map(|bytes| bytes.to_vec()) +} + +fn http_client() -> Result { + Client::builder() + .user_agent(format!("runseal-helper/{}", env!("CARGO_PKG_VERSION"))) + .build() + .context("failed to build helper HTTP client") +} + +fn render_alias_template(template: &str, name: &str, version: &str) -> String { + template + .replace("{name}", name) + .replace("", name) + .replace("{version}", version) + .replace("", version) +} + +fn validate_alias(alias: &str) -> Result<()> { + if alias.is_empty() { + bail!("helper alias cannot be empty") + } + if !alias + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + bail!( + "helper alias must contain only ASCII letters, numbers, `-`, or `_`: {}", + alias + ) + } + Ok(()) +} + +fn is_http_url(value: &str) -> bool { + value.starts_with("https://") || value.starts_with("http://") +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(path) + .with_context(|| format!("failed to read helper metadata: {}", path.display()))? + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(path, permissions) + .with_context(|| format!("failed to set helper executable bit: {}", path.display())) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/src/lib.rs b/app/src/lib.rs similarity index 89% rename from src/lib.rs rename to app/src/lib.rs index cef4a28..219e53a 100644 --- a/src/lib.rs +++ b/app/src/lib.rs @@ -1,7 +1,7 @@ pub mod commands; pub mod core; +pub mod helpers; pub mod logging; -pub mod plugins; pub use commands::preview; pub use commands::self_update; diff --git a/src/logging.rs b/app/src/logging.rs similarity index 94% rename from src/logging.rs rename to app/src/logging.rs index 39148fd..c2d03a3 100644 --- a/src/logging.rs +++ b/app/src/logging.rs @@ -6,7 +6,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use crate::core::config::{RawEnv, resolve_envlock_home}; +use crate::core::config::{RawEnv, resolve_runseal_home}; static CURRENT_LOG_FILE: OnceLock = OnceLock::new(); @@ -41,11 +41,11 @@ impl Write for SharedFileWriter { } pub fn prepare_session_log(raw_env: &RawEnv, command_slug: &str) -> Result { - let log_root = std::env::var_os("ENVLOCK_LOG_HOME") + let log_root = std::env::var_os("RUNSEAL_LOG_HOME") .map(PathBuf::from) .filter(|path| !path.as_os_str().is_empty()) .map(Ok) - .unwrap_or_else(|| resolve_envlock_home(raw_env).map(|path| path.join("logs")))?; + .unwrap_or_else(|| resolve_runseal_home(raw_env).map(|path| path.join("logs")))?; std::fs::create_dir_all(&log_root) .with_context(|| format!("failed to create log directory: {}", log_root.display()))?; diff --git a/tests/alias.rs b/app/tests/alias.rs similarity index 55% rename from tests/alias.rs rename to app/tests/alias.rs index 10c8985..70b482f 100644 --- a/tests/alias.rs +++ b/app/tests/alias.rs @@ -5,18 +5,18 @@ use tempfile::TempDir; #[test] fn alias_append_and_list_work() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - std::fs::create_dir_all(envlock_home.join("profiles")) + let runseal_home = temp.path().join("runseal-home"); + std::fs::create_dir_all(runseal_home.join("profiles")) .expect("profiles directory should be created"); - let profile = envlock_home.join("profiles/work.json"); + let profile = runseal_home.join("profiles/work.json"); std::fs::write( &profile, - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"work"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"work"}}]}"#, ) .expect("profile should be written"); - let append = Command::new(env!("CARGO_BIN_EXE_envlock")) + let append = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "alias", "append", @@ -24,16 +24,16 @@ fn alias_append_and_list_work() { "--profile", profile.to_str().expect("path should be UTF-8"), ]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(append.status.success()); - let list = Command::new(env!("CARGO_BIN_EXE_envlock")) + let list = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["alias", "list"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(list.status.success()); let stdout = String::from_utf8(list.stdout).expect("stdout should be UTF-8"); @@ -43,18 +43,18 @@ fn alias_append_and_list_work() { #[test] fn shortcut_alias_runs_profile() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - std::fs::create_dir_all(envlock_home.join("profiles")) + let runseal_home = temp.path().join("runseal-home"); + std::fs::create_dir_all(runseal_home.join("profiles")) .expect("profiles directory should be created"); - let profile = envlock_home.join("profiles/work.json"); + let profile = runseal_home.join("profiles/work.json"); std::fs::write( &profile, - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"from-alias"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"from-alias"}}]}"#, ) .expect("profile should be written"); - let append = Command::new(env!("CARGO_BIN_EXE_envlock")) + let append = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "alias", "append", @@ -62,37 +62,37 @@ fn shortcut_alias_runs_profile() { "--profile", profile.to_str().expect("path should be UTF-8"), ]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(append.status.success()); - let run = Command::new(env!("CARGO_BIN_EXE_envlock")) + let run = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([":work"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(run.status.success()); let stdout = String::from_utf8(run.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("export ENVLOCK_PROFILE='from-alias'")); + assert!(stdout.contains("export RUNSEAL_PROFILE='from-alias'")); } #[test] fn alias_run_executes_explicit_entrypoint() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - std::fs::create_dir_all(envlock_home.join("profiles")) + let runseal_home = temp.path().join("runseal-home"); + std::fs::create_dir_all(runseal_home.join("profiles")) .expect("profiles directory should be created"); - let profile = envlock_home.join("profiles/work.json"); + let profile = runseal_home.join("profiles/work.json"); std::fs::write( &profile, - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"from-run"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"from-run"}}]}"#, ) .expect("profile should be written"); - let append = Command::new(env!("CARGO_BIN_EXE_envlock")) + let append = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "alias", "append", @@ -100,43 +100,43 @@ fn alias_run_executes_explicit_entrypoint() { "--profile", profile.to_str().expect("path should be UTF-8"), ]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(append.status.success()); - let run = Command::new(env!("CARGO_BIN_EXE_envlock")) + let run = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["alias", "run", "work"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(run.status.success()); let stdout = String::from_utf8(run.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("export ENVLOCK_PROFILE='from-run'")); + assert!(stdout.contains("export RUNSEAL_PROFILE='from-run'")); } #[test] fn bare_alias_name_is_not_supported() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - std::fs::create_dir_all(envlock_home.join("profiles")) + let runseal_home = temp.path().join("runseal-home"); + std::fs::create_dir_all(runseal_home.join("profiles")) .expect("profiles directory should be created"); - let profile = envlock_home.join("profiles/work.json"); - let default_profile = envlock_home.join("profiles/default.json"); + let profile = runseal_home.join("profiles/work.json"); + let default_profile = runseal_home.join("profiles/default.json"); std::fs::write( &profile, - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"from-run"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"from-run"}}]}"#, ) .expect("profile should be written"); std::fs::write( &default_profile, - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"default"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"default"}}]}"#, ) .expect("default profile should be written"); - let append = Command::new(env!("CARGO_BIN_EXE_envlock")) + let append = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "alias", "append", @@ -144,16 +144,16 @@ fn bare_alias_name_is_not_supported() { "--profile", profile.to_str().expect("path should be UTF-8"), ]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(append.status.success()); - let run = Command::new(env!("CARGO_BIN_EXE_envlock")) + let run = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["work"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(!run.status.success()); let stderr = String::from_utf8(run.stderr).expect("stderr should be UTF-8"); diff --git a/tests/command_mode.rs b/app/tests/command_mode.rs similarity index 86% rename from tests/command_mode.rs rename to app/tests/command_mode.rs index 42b95b0..3ba51a8 100644 --- a/tests/command_mode.rs +++ b/app/tests/command_mode.rs @@ -11,7 +11,7 @@ fn write_profile(dir: &TempDir) -> String { { "type": "env", "vars": { - "ENVLOCK_PROFILE": "from-command-mode" + "RUNSEAL_PROFILE": "from-command-mode" } } ] @@ -29,7 +29,7 @@ fn command_mode_runs_child_with_exported_envs() { let temp = TempDir::new().expect("temp dir should be created"); let profile_path = write_profile(&temp); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "-p", &profile_path, @@ -38,10 +38,10 @@ fn command_mode_runs_child_with_exported_envs() { "--", "bash", "-lc", - "printf '%s' \"$ENVLOCK_PROFILE\"", + "printf '%s' \"$RUNSEAL_PROFILE\"", ]) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); @@ -53,7 +53,7 @@ fn command_mode_propagates_child_exit_code() { let temp = TempDir::new().expect("temp dir should be created"); let profile_path = write_profile(&temp); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "-p", &profile_path, @@ -65,7 +65,7 @@ fn command_mode_propagates_child_exit_code() { "exit 17", ]) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert_eq!(output.status.code(), Some(17)); } @@ -98,7 +98,7 @@ fn command_mode_resolves_resource_content_uri() { ) .expect("profile should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "-p", profile @@ -111,9 +111,9 @@ fn command_mode_resolves_resource_content_uri() { "-lc", "printf '%s' \"$OPENCODE_CONFIG_CONTENT\"", ]) - .env("ENVLOCK_RESOURCE_HOME", &resource_home) + .env("RUNSEAL_RESOURCE_HOME", &resource_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); @@ -147,7 +147,7 @@ fn command_mode_honors_strict_duplicate_key_checks() { ) .expect("profile should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "-p", profile @@ -162,7 +162,7 @@ fn command_mode_honors_strict_duplicate_key_checks() { "printf '%s' should-not-run", ]) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!( !output.status.success(), diff --git a/tests/default_profile.rs b/app/tests/default_profile.rs similarity index 61% rename from tests/default_profile.rs rename to app/tests/default_profile.rs index a111f78..b1c0d03 100644 --- a/tests/default_profile.rs +++ b/app/tests/default_profile.rs @@ -3,50 +3,50 @@ use std::process::Command; use tempfile::TempDir; #[test] -fn uses_default_profile_from_envlock_home() { +fn uses_default_profile_from_runseal_home() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - let profiles_dir = envlock_home.join("profiles"); + let runseal_home = temp.path().join("runseal-home"); + let profiles_dir = runseal_home.join("profiles"); std::fs::create_dir_all(&profiles_dir).expect("profiles dir should be created"); std::fs::write( profiles_dir.join("default.json"), - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"from-default"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"from-default"}}]}"#, ) .expect("default profile should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["--output", "json", "--log-level", "error"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("\"ENVLOCK_PROFILE\": \"from-default\"")); + assert!(stdout.contains("\"RUNSEAL_PROFILE\": \"from-default\"")); } #[test] fn profile_flag_overrides_default_profile() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - let profiles_dir = envlock_home.join("profiles"); + let runseal_home = temp.path().join("runseal-home"); + let profiles_dir = runseal_home.join("profiles"); std::fs::create_dir_all(&profiles_dir).expect("profiles dir should be created"); std::fs::write( profiles_dir.join("default.json"), - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"from-default"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"from-default"}}]}"#, ) .expect("default profile should be written"); let explicit_profile = temp.path().join("explicit.json"); std::fs::write( &explicit_profile, - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"from-profile"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"from-profile"}}]}"#, ) .expect("explicit profile should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "-p", explicit_profile @@ -57,26 +57,26 @@ fn profile_flag_overrides_default_profile() { "--log-level", "error", ]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); - assert!(stdout.contains("\"ENVLOCK_PROFILE\": \"from-profile\"")); - assert!(!stdout.contains("\"ENVLOCK_PROFILE\": \"from-default\"")); + assert!(stdout.contains("\"RUNSEAL_PROFILE\": \"from-profile\"")); + assert!(!stdout.contains("\"RUNSEAL_PROFILE\": \"from-default\"")); } #[test] fn fails_when_default_profile_missing() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); + let runseal_home = temp.path().join("runseal-home"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["--output", "json", "--log-level", "error"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(!output.status.success()); let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); diff --git a/app/tests/helper_node.rs b/app/tests/helper_node.rs new file mode 100644 index 0000000..4ede9af --- /dev/null +++ b/app/tests/helper_node.rs @@ -0,0 +1,630 @@ +use std::process::Command; + +use tempfile::TempDir; + +fn helper_alias_template() -> String { + format!("{}/../helpers/{{name}}.sh", env!("CARGO_MANIFEST_DIR")) +} + +fn runseal_command() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); + command.env("RUNSEAL_HELPER_ALIAS_TEMPLATE", helper_alias_template()); + command +} + +fn profile_path(runseal_home: &std::path::Path, version: &str) -> std::path::PathBuf { + let profile = runseal_home.join("profiles").join("node-test.json"); + std::fs::create_dir_all(profile.parent().unwrap()).expect("profiles dir should exist"); + let content = format!( + r#"{{ + "schema": "runseal.profile.v1", + "meta": {{ "name": "node-test" }}, + "injections": [ + {{ + "type": "env", + "ops": [ + {{ "op": "set", "key": "RUNSEAL_HELPER_NODE_HOME", "value": "{home}/helpers/node" }}, + {{ "op": "set", "key": "RUNSEAL_NODE_VERSION", "value": "{version}" }}, + {{ "op": "set", "key": "RUNSEAL_NODE_BIN", "value": "{home}/helpers/node/versions/v{version}/bin/node" }}, + {{ "op": "set", "key": "RUNSEAL_COREPACK_SHIMS", "value": "{home}/helpers/node/versions/v{version}/corepack-bin" }}, + {{ "op": "set", "key": "COREPACK_HOME", "value": "{home}/helpers/node/versions/v{version}/cache/corepack" }}, + {{ "op": "set", "key": "NPM_CONFIG_CACHE", "value": "{home}/helpers/node/versions/v{version}/cache/npm" }}, + {{ "op": "set", "key": "NPM_CONFIG_PREFIX", "value": "{home}/helpers/node/versions/v{version}" }}, + {{ "op": "set", "key": "npm_config_prefix", "value": "{home}/helpers/node/versions/v{version}" }}, + {{ "op": "prepend", "key": "PATH", "value": "{home}/helpers/node/versions/v{version}/node_modules/.bin", "separator": ":" }}, + {{ "op": "prepend", "key": "PATH", "value": "{home}/helpers/node/versions/v{version}/corepack-bin", "separator": ":" }}, + {{ "op": "prepend", "key": "PATH", "value": "{home}/helpers/node/versions/v{version}/bin", "separator": ":" }} + ] + }} + ] +}}"#, + home = runseal_home.display(), + version = version + ); + std::fs::write(&profile, content).expect("profile should be written"); + profile +} + +#[test] +fn helper_node_example_prints_profile_and_dirty_boundaries() { + let output = runseal_command() + .args(["helper", ":node", "example"]) + .output() + .expect("example command should run"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("\"schema\": \"runseal.profile.v1\"")); + assert!(stdout.contains("RUNSEAL_HELPER_NODE_HOME")); + assert!(stdout.contains("dirty boundaries to keep sealed")); + assert!(stdout.contains("versions/vX.Y.Z/bin")); +} + +#[test] +fn helper_node_remote_list_returns_versions() { + let output = runseal_command() + .args(["helper", ":node", "remote", "list"]) + .output() + .expect("remote list should run"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + let first = stdout + .lines() + .next() + .expect("remote list should have entries"); + assert!(first.chars().next().unwrap().is_ascii_digit()); +} + +#[test] +fn helper_node_install_requires_explicit_version() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + + let output = runseal_command() + .args(["helper", ":node", "install"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install command should run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr should be UTF-8"); + assert!(stderr.contains("install requires --node-version")); +} + +#[test] +fn helper_node_install_creates_layout() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + + let output = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("runseal command should run"); + + assert!(output.status.success()); + let install_home = runseal_home.join("helpers/node/versions/v24.12.0"); + assert!(install_home.join("bin/node").exists()); + assert!(install_home.join("bin/npm").exists()); + assert!(install_home.join("node_modules").exists()); + assert!(install_home.join("lib/node_modules/npm").exists()); + assert!(install_home.join(".lock").exists() || !install_home.join(".lock").exists()); +} + +#[test] +fn helper_node_install_emits_patch() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + + let output = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install command should run"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("\"schema\": \"runseal.patch.v1\"")); + assert!(stdout.contains("\"RUNSEAL_COREPACK_SHIMS\"")); + assert!(stdout.contains("\"COREPACK_HOME\"")); + assert!(stdout.contains("\"RUNSEAL_NODE_BIN\"")); + assert!(stdout.contains("\"NPM_CONFIG_PREFIX\"")); + assert!(stdout.contains("corepack-bin")); + assert!(stdout.contains("node_modules/.bin")); +} + +#[test] +fn helper_node_list_and_which_report_installed_version() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let list = runseal_command() + .args(["helper", ":node", "list"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("list should run"); + assert!(list.status.success()); + let list_stdout = String::from_utf8(list.stdout).expect("stdout should be UTF-8"); + assert!(list_stdout.contains("24.12.0")); + + let which = runseal_command() + .args(["helper", ":node", "which", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("which should run"); + assert!(which.status.success()); + let which_stdout = String::from_utf8(which.stdout).expect("stdout should be UTF-8"); + assert!(which_stdout.contains("version=24.12.0")); + assert!(which_stdout.contains("bin/node")); + assert!(which_stdout.contains("bin/npm")); + assert!(which_stdout.contains("corepack_shims=")); +} + +#[test] +fn helper_node_snapshot_reports_existing_install() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let snapshot = runseal_command() + .args(["helper", ":node", "snapshot", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("snapshot should run"); + assert!(snapshot.status.success()); + let stdout = String::from_utf8(snapshot.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("\"schema\": \"runseal.node.snapshot.v1\"")); + assert!(stdout.contains("\"version\": \"24.12.0\"")); + assert!(stdout.contains("\"bin_dir\"")); +} + +#[test] +fn helper_node_uninstall_removes_single_version() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let version_root = runseal_home.join("helpers/node/versions/v24.12.0"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + assert!(version_root.exists()); + + let uninstall = runseal_command() + .args(["helper", ":node", "uninstall", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("uninstall should run"); + assert!(uninstall.status.success()); + assert!(!version_root.exists()); +} + +#[test] +fn helper_node_profile_runtime_can_install_pnpm_globally() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile = profile_path(&runseal_home, "24.12.0"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let runtime = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "npm", + "i", + "-g", + "pnpm", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("runtime command should run"); + assert!(runtime.status.success()); + + let version_root = runseal_home.join("helpers/node/versions/v24.12.0"); + assert!(version_root.join("bin/pnpm").exists()); + assert!(version_root.join("lib/node_modules/pnpm").exists()); +} + +#[test] +fn helper_node_profile_runtime_can_prepare_pnpm_with_corepack() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile = profile_path(&runseal_home, "24.12.0"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let runtime = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "corepack", + "prepare", + "pnpm@10.30.3", + "--activate", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("runtime command should run"); + assert!(runtime.status.success()); + + let version_root = runseal_home.join("helpers/node/versions/v24.12.0"); + assert!(version_root.join("cache/corepack").exists()); + assert!(version_root.join("corepack-bin/pnpm").exists()); + + let pnpm = runseal_command() + .args(["--profile", profile.to_str().unwrap(), "pnpm", "--version"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("pnpm should run through profile"); + assert!(pnpm.status.success()); +} + +#[test] +fn helper_node_profile_runtime_can_enable_pnpm_with_corepack() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile = profile_path(&runseal_home, "24.12.0"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let enable = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "corepack", + "enable", + "pnpm", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("corepack enable should run"); + assert!(enable.status.success()); + + let which = runseal_command() + .args(["helper", ":node", "which", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("which should run"); + assert!(which.status.success()); + let stdout = String::from_utf8(which.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("corepack_shims=")); +} + +#[test] +fn helper_node_profile_runtime_records_corepack_yarn_prepare_state() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile = profile_path(&runseal_home, "24.12.0"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let runtime = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "corepack", + "prepare", + "yarn@1.22.22", + "--activate", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("runtime command should run"); + assert!(runtime.status.success()); + + let version_root = runseal_home.join("helpers/node/versions/v24.12.0"); + assert!(version_root.join("cache/corepack").exists()); + assert!(version_root.join("corepack-bin").exists()); + let corepack_cache_entries = std::fs::read_dir(version_root.join("cache/corepack")) + .expect("corepack cache dir should exist") + .count(); + assert!(corepack_cache_entries > 0); +} + +#[test] +fn helper_node_profile_runtime_can_enable_yarn_with_corepack() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile = profile_path(&runseal_home, "24.12.0"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let enable = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "corepack", + "enable", + "yarn", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("corepack enable should run"); + assert!(enable.status.success()); + + let version_root = runseal_home.join("helpers/node/versions/v24.12.0"); + assert!(version_root.join("corepack-bin/yarn").exists()); +} + +#[test] +fn helper_node_profile_runtime_supports_project_local_pnpm_workflow() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let project_dir = temp.path().join("pnpm-project"); + let profile = profile_path(&runseal_home, "24.12.0"); + std::fs::create_dir_all(&project_dir).expect("project dir should be created"); + std::fs::write( + project_dir.join("package.json"), + r#"{"name":"pnpm-project","version":"1.0.0","scripts":{"hello":"node -e \"console.log('hello from pnpm')\""}}"#, + ) + .expect("package json should be written"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let absorb = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "npm", + "i", + "-g", + "pnpm", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("pnpm absorb should run"); + assert!(absorb.status.success()); + + let add = runseal_command() + .current_dir(&project_dir) + .args([ + "--profile", + profile.to_str().unwrap(), + "pnpm", + "add", + "is-number@7.0.0", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("pnpm add should run"); + assert!( + add.status.success(), + "stderr: {}", + String::from_utf8_lossy(&add.stderr) + ); + + let run = runseal_command() + .current_dir(&project_dir) + .args([ + "--profile", + profile.to_str().unwrap(), + "pnpm", + "run", + "hello", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("pnpm run should execute"); + assert!(run.status.success()); + let stdout = String::from_utf8(run.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("hello from pnpm")); + assert!(project_dir.join("pnpm-lock.yaml").exists()); + assert!(project_dir.join("node_modules").exists()); +} + +#[test] +fn helper_node_profile_runtime_supports_project_local_yarn_workflow() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let project_dir = temp.path().join("yarn-project"); + let profile = profile_path(&runseal_home, "24.12.0"); + std::fs::create_dir_all(&project_dir).expect("project dir should be created"); + std::fs::write( + project_dir.join("package.json"), + r#"{"name":"yarn-project","version":"1.0.0","scripts":{"hello":"node -e \"console.log('hello from yarn')\""}}"#, + ) + .expect("package json should be written"); + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let absorb = runseal_command() + .args([ + "--profile", + profile.to_str().unwrap(), + "npm", + "i", + "-g", + "yarn", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("yarn absorb should run"); + assert!(absorb.status.success()); + + let add = runseal_command() + .current_dir(&project_dir) + .args([ + "--profile", + profile.to_str().unwrap(), + "yarn", + "add", + "is-number@7.0.0", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("yarn add should run"); + assert!( + add.status.success(), + "stderr: {}", + String::from_utf8_lossy(&add.stderr) + ); + + let run = runseal_command() + .current_dir(&project_dir) + .args([ + "--profile", + profile.to_str().unwrap(), + "yarn", + "run", + "hello", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("yarn run should execute"); + assert!(run.status.success()); + let stdout = String::from_utf8(run.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("hello from yarn")); + assert!(project_dir.join("yarn.lock").exists()); + assert!(project_dir.join("node_modules").exists()); +} + +#[test] +fn helper_node_profile_runtime_beats_host_path_contamination() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile = profile_path(&runseal_home, "24.12.0"); + let host_bin = temp.path().join("host-bin"); + std::fs::create_dir_all(&host_bin).expect("host bin dir should be created"); + std::fs::write( + host_bin.join("node"), + "#!/usr/bin/env bash\necho host-node\n", + ) + .expect("fake host node should be written"); + std::fs::write(host_bin.join("npm"), "#!/usr/bin/env bash\necho host-npm\n") + .expect("fake host npm should be written"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [host_bin.join("node"), host_bin.join("npm")] { + let mut permissions = std::fs::metadata(&path) + .expect("metadata should exist") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("permissions should be set"); + } + } + + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + + let runtime = runseal_command() + .args(["--profile", profile.to_str().unwrap(), "node", "--version"]) + .env("RUNSEAL_HOME", &runseal_home) + .env( + "PATH", + format!("{}:{}", host_bin.display(), std::env::var("PATH").unwrap()), + ) + .output() + .expect("runtime command should run"); + assert!(runtime.status.success()); + let stdout = String::from_utf8(runtime.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("v24.12.0")); + assert!(!stdout.contains("host-node")); +} + +#[test] +fn helper_node_versions_stay_isolated() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profile24 = profile_path(&runseal_home, "24.12.0"); + let profile22 = runseal_home.join("profiles").join("node22.json"); + let content22 = std::fs::read_to_string(&profile24) + .expect("profile24 should exist") + .replace("24.12.0", "22.12.0") + .replace("node-test", "node22"); + std::fs::write(&profile22, content22).expect("profile22 should be written"); + + for version in ["24.12.0", "22.12.0"] { + let install = runseal_command() + .args(["helper", ":node", "install", "--node-version", version]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("install should run"); + assert!(install.status.success()); + } + + let list = runseal_command() + .args(["helper", ":node", "list"]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("list should run"); + assert!(list.status.success()); + let stdout = String::from_utf8(list.stdout).expect("stdout should be UTF-8"); + assert!(stdout.contains("24.12.0")); + assert!(stdout.contains("22.12.0")); + + let pnpm24 = runseal_command() + .args([ + "--profile", + profile24.to_str().unwrap(), + "npm", + "i", + "-g", + "pnpm", + ]) + .env("RUNSEAL_HOME", &runseal_home) + .output() + .expect("pnpm install should run"); + assert!(pnpm24.status.success()); + + let root24 = runseal_home.join("helpers/node/versions/v24.12.0"); + let root22 = runseal_home.join("helpers/node/versions/v22.12.0"); + assert!(root24.join("bin/pnpm").exists()); + assert!(!root22.join("bin/pnpm").exists()); +} diff --git a/app/tests/logging.rs b/app/tests/logging.rs new file mode 100644 index 0000000..723231d --- /dev/null +++ b/app/tests/logging.rs @@ -0,0 +1,155 @@ +use std::process::Command; + +use tempfile::TempDir; + +fn helper_alias_template() -> String { + format!("{}/../helpers/{{name}}.sh", env!("CARGO_MANIFEST_DIR")) +} + +fn runseal_command() -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_runseal")); + command.env("RUNSEAL_HELPER_ALIAS_TEMPLATE", helper_alias_template()); + command +} + +#[test] +fn logs_go_to_stderr_and_exports_stay_on_stdout() { + let output = runseal_command() + .args(["-p", "examples/runseal.sample.json", "--log-level", "info"]) + .env_remove("RUST_LOG") + .output() + .expect("runseal command should run"); + + assert!(output.status.success()); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8"); + let stderr = String::from_utf8(output.stderr).expect("stderr should be valid UTF-8"); + + assert!(stdout.contains("export RUNSEAL_PROFILE='dev'")); + assert!(stderr.contains("runseal run started")); + assert!(!stdout.contains("runseal run started")); +} + +#[test] +fn helper_node_writes_per_invocation_log_file() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let log_home = temp.path().join("logs"); + + std::fs::create_dir_all(&log_home).expect("log dir should be created"); + + let before = std::fs::read_dir(&log_home) + .expect("log dir should exist") + .count(); + + let preview = runseal_command() + .args(["helper", ":node", "install", "--node-version", "24.12.0"]) + .env("RUNSEAL_HOME", &runseal_home) + .env("RUNSEAL_LOG_HOME", &log_home) + .output() + .expect("install command should run"); + assert!(preview.status.success()); + + let entries: Vec<_> = std::fs::read_dir(&log_home) + .expect("log dir should be readable") + .map(|entry| entry.expect("entry should be readable").path()) + .collect(); + assert!(entries.len() > before); + let newest = entries + .iter() + .max_by_key(|path| std::fs::metadata(path).and_then(|m| m.modified()).ok()) + .expect("at least one log file should exist"); + let contents = std::fs::read_to_string(newest).expect("log file should be readable"); + assert!(contents.contains("helper execution starting") || contents.contains("method=install")); + assert!(contents.contains("node_mirror=") || contents.contains("downloaded node runtime")); + assert!( + contents.contains("dirs prepared node_home=") || contents.contains("lock acquired dir=") + ); + assert!( + contents.contains("patch emitted env_count=6") + || contents.contains("patch emitted env_count=8") + ); +} + +#[test] +fn helper_node_failure_prints_log_path_and_writes_failure_trail() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let log_home = temp.path().join("logs"); + + std::fs::create_dir_all(&log_home).expect("log dir should be created"); + + let preview = runseal_command() + .args([ + "helper", + ":node", + "install", + "--node-version", + "not-a-version", + ]) + .env("RUNSEAL_HELPER_NODE_MIRROR", "https://nodejs.org/dist") + .env("RUNSEAL_HOME", &runseal_home) + .env("RUNSEAL_LOG_HOME", &log_home) + .output() + .expect("install command should run"); + assert!(!preview.status.success()); + + let stderr = String::from_utf8(preview.stderr).expect("stderr should be valid UTF-8"); + assert!(stderr.contains("See log:")); + + let entries: Vec<_> = std::fs::read_dir(&log_home) + .expect("log dir should be readable") + .map(|entry| entry.expect("entry should be readable").path()) + .collect(); + let newest = entries + .iter() + .max_by_key(|path| std::fs::metadata(path).and_then(|m| m.modified()).ok()) + .expect("at least one log file should exist"); + let contents = std::fs::read_to_string(newest).expect("log file should be readable"); + assert!( + contents.contains("helper execution failed") + || contents.contains("failed to fetch helper script") + || contents.contains("download node runtime") + ); + assert!(contents.contains("runseal invocation failed")); + assert!(contents.contains("runseal invocation failed")); +} + +#[test] +fn unwritable_log_home_disables_file_logging_without_failing_command() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let log_home = temp.path().join("logs-file"); + let profile = runseal_home.join("profiles/default.json"); + + std::fs::create_dir_all(profile.parent().expect("profile dir should exist")) + .expect("profile dir should be created"); + std::fs::write(&log_home, "not a directory").expect("log path sentinel should be written"); + std::fs::write( + &profile, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"default"}}]}"#, + ) + .expect("profile should be written"); + + let output = runseal_command() + .args(["preview", "-p", profile.to_str().unwrap()]) + .env("RUNSEAL_HOME", &runseal_home) + .env("RUNSEAL_LOG_HOME", &log_home) + .output() + .expect("preview command should run"); + + assert!( + output.status.success(), + "preview should continue without file logging, stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stderr = String::from_utf8(output.stderr).expect("stderr should be valid UTF-8"); + assert!(stderr.contains("Warning: session file logging disabled")); + assert!( + stderr.contains("failed to create log directory") + || stderr.contains("failed to open session log file") + ); + assert!(!stderr.contains("See log:")); + assert!(log_home.is_file()); +} diff --git a/tests/output_mode.rs b/app/tests/output_mode.rs similarity index 80% rename from tests/output_mode.rs rename to app/tests/output_mode.rs index 4c5fb6c..a7b0357 100644 --- a/tests/output_mode.rs +++ b/app/tests/output_mode.rs @@ -6,10 +6,10 @@ use serde_json::Value; #[test] fn output_json_mode_prints_json_object() { - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "-p", - "examples/envlock.sample.json", + "examples/runseal.sample.json", "--output", "json", "--log-level", @@ -17,7 +17,7 @@ fn output_json_mode_prints_json_object() { ]) .env_remove("RUST_LOG") .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); @@ -27,8 +27,8 @@ fn output_json_mode_prints_json_object() { let keys: BTreeSet<&str> = obj.keys().map(|k| k.as_str()).collect(); let expected: BTreeSet<&str> = BTreeSet::from([ - "ENVLOCK_PROFILE", - "ENVLOCK_NODE_VERSION", + "RUNSEAL_PROFILE", + "RUNSEAL_NODE_VERSION", "NPM_CONFIG_REGISTRY", "KUBECONFIG_CONTEXT", "KUBECONFIG_NAMESPACE", @@ -41,18 +41,18 @@ fn output_json_mode_prints_json_object() { #[test] fn shell_output_can_be_evaluated_and_consumed() { - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) - .args(["-p", "examples/envlock.sample.json", "--log-level", "error"]) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) + .args(["-p", "examples/runseal.sample.json", "--log-level", "error"]) .env_remove("RUST_LOG") .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); let shell_exports = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8"); let mut child = Command::new("bash") .args([ "-lc", - "set -e; eval \"$(cat)\"; printf '%s|%s|%s\\n' \"$ENVLOCK_PROFILE\" \"$ENVLOCK_NODE_VERSION\" \"$KUBECONFIG_CONTEXT\"", + "set -e; eval \"$(cat)\"; printf '%s|%s|%s\\n' \"$RUNSEAL_PROFILE\" \"$RUNSEAL_NODE_VERSION\" \"$KUBECONFIG_CONTEXT\"", ]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/tests/preview.rs b/app/tests/preview.rs similarity index 93% rename from tests/preview.rs rename to app/tests/preview.rs index 8886a00..3124426 100644 --- a/tests/preview.rs +++ b/app/tests/preview.rs @@ -17,7 +17,7 @@ fn preview_text_only_exposes_keys_and_metadata() { { "type": "env", "vars": { - "ENVLOCK_TOKEN": "super-secret-token" + "RUNSEAL_TOKEN": "super-secret-token" }, "ops": [ { @@ -42,7 +42,7 @@ fn preview_text_only_exposes_keys_and_metadata() { ) .expect("profile file should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "preview", "--profile", @@ -58,7 +58,7 @@ fn preview_text_only_exposes_keys_and_metadata() { ); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); assert!(stdout.contains("[env]")); - assert!(stdout.contains("ENVLOCK_TOKEN")); + assert!(stdout.contains("RUNSEAL_TOKEN")); assert!(stdout.contains("API_KEY")); assert!(stdout.contains("[command]")); assert!(stdout.contains("program=fnm")); @@ -90,7 +90,7 @@ fn preview_json_has_stable_shape_without_sensitive_values() { ) .expect("profile file should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args([ "preview", "--profile", diff --git a/tests/profiles.rs b/app/tests/profiles.rs similarity index 60% rename from tests/profiles.rs rename to app/tests/profiles.rs index a4c1489..378af33 100644 --- a/tests/profiles.rs +++ b/app/tests/profiles.rs @@ -5,35 +5,35 @@ use tempfile::TempDir; #[test] fn profiles_init_creates_default_profile() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); + let runseal_home = temp.path().join("runseal-home"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["profiles", "init", "--type", "minimal"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); - assert!(envlock_home.join("profiles/default.json").is_file()); + assert!(runseal_home.join("profiles/default.json").is_file()); } #[test] fn profiles_status_reports_existing_profiles() { let temp = TempDir::new().expect("temp dir should be created"); - let envlock_home = temp.path().join("envlock-home"); - let profiles = envlock_home.join("profiles"); + let runseal_home = temp.path().join("runseal-home"); + let profiles = runseal_home.join("profiles"); std::fs::create_dir_all(&profiles).expect("profiles directory should be created"); std::fs::write( profiles.join("default.json"), - r#"{"injections":[{"type":"env","vars":{"ENVLOCK_PROFILE":"default"}}]}"#, + r#"{"injections":[{"type":"env","vars":{"RUNSEAL_PROFILE":"default"}}]}"#, ) .expect("profile should be written"); - let output = Command::new(env!("CARGO_BIN_EXE_envlock")) + let output = Command::new(env!("CARGO_BIN_EXE_runseal")) .args(["profiles", "status"]) - .env("ENVLOCK_HOME", &envlock_home) + .env("RUNSEAL_HOME", &runseal_home) .output() - .expect("envlock command should run"); + .expect("runseal command should run"); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8"); diff --git a/app/tests/unit/commands/self_update.rs b/app/tests/unit/commands/self_update.rs new file mode 100644 index 0000000..65c59ed --- /dev/null +++ b/app/tests/unit/commands/self_update.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn parse_semver_accepts_v_prefix() { + let v = parse_semver("v1.2.3").expect("semver should parse"); + assert_eq!(v, Version::new(1, 2, 3)); +} + +#[test] +fn parse_checksum_reads_sha256sum_format() { + let checksums = "abc123 runseal-v0.2.0-x86_64-unknown-linux-gnu.tar.gz\n"; + let v = parse_checksum(checksums, "runseal-v0.2.0-x86_64-unknown-linux-gnu.tar.gz"); + assert_eq!(v.as_deref(), Some("abc123")); +} + +#[test] +fn parse_checksum_reads_shasum_star_format() { + let checksums = "def456 *runseal-v0.2.0-x86_64-unknown-linux-gnu.tar.gz\n"; + let v = parse_checksum(checksums, "runseal-v0.2.0-x86_64-unknown-linux-gnu.tar.gz"); + assert_eq!(v.as_deref(), Some("def456")); +} + +#[test] +fn managed_install_path_is_home_scoped() { + let path = managed_install_binary_path_with_home(Some(PathBuf::from("/tmp/runseal-home"))) + .expect("managed path should build"); + assert_eq!( + path, + PathBuf::from("/tmp/runseal-home/.runseal/bin/runseal") + ); +} + +#[test] +fn normalize_release_tag_adds_prefix_once() { + assert_eq!(normalize_release_tag("0.2.1"), "v0.2.1"); + assert_eq!(normalize_release_tag("v0.2.1"), "v0.2.1"); +} + +#[test] +fn release_metadata_url_uses_expected_endpoint() { + assert_eq!( + release_metadata_url(None), + "https://api.github.com/repos/PerishCode/runseal/releases/latest" + ); + assert_eq!( + release_metadata_url(Some("0.2.1")), + "https://api.github.com/repos/PerishCode/runseal/releases/tags/v0.2.1" + ); +} + +#[test] +fn release_highlights_extracts_light_changelog_lines() { + let body = "# Release v0.3.0\n\n- add meta-first docs\n- tighten converge checks\n\nSee details below."; + let items = release_highlights(Some(body), 3); + assert_eq!( + items, + vec![ + "add meta-first docs".to_string(), + "tighten converge checks".to_string(), + "See details below.".to_string() + ] + ); +} + +#[test] +fn release_highlights_handles_missing_body() { + let items = release_highlights(None, 3); + assert!(items.is_empty()); +} + +#[test] +fn docs_changelog_tag_match_normalizes_prefix() { + let release = DocsChangelogRelease { + tag: "0.2.1".to_string(), + highlights: vec!["line".to_string()], + }; + + let matched = normalize_release_tag(&release.tag) == normalize_release_tag("v0.2.1"); + assert!(matched); +} diff --git a/app/tests/unit/commands/skill.rs b/app/tests/unit/commands/skill.rs new file mode 100644 index 0000000..fd36d01 --- /dev/null +++ b/app/tests/unit/commands/skill.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn normalize_release_tag_adds_prefix_once() { + assert_eq!(normalize_release_tag("0.1.0"), "v0.1.0"); + assert_eq!(normalize_release_tag("v0.1.0"), "v0.1.0"); +} + +#[test] +fn parse_checksum_reads_standard_and_star_format() { + let checksums = "abc123 skill-v0.1.0.zip\ndef456 *skill-v0.1.1.zip\n"; + assert_eq!( + parse_checksum(checksums, "skill-v0.1.0.zip").as_deref(), + Some("abc123") + ); + assert_eq!( + parse_checksum(checksums, "skill-v0.1.1.zip").as_deref(), + Some("def456") + ); +} diff --git a/app/tests/unit/core/alias_store.rs b/app/tests/unit/core/alias_store.rs new file mode 100644 index 0000000..2b33a24 --- /dev/null +++ b/app/tests/unit/core/alias_store.rs @@ -0,0 +1,31 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn append_and_persist_alias() { + let temp = TempDir::new().expect("temp dir should be created"); + let mut store = AliasStore::default(); + store + .append("work".to_string(), "profiles/work.json".to_string()) + .expect("append should succeed"); + let path = store.save(temp.path()).expect("save should succeed"); + assert!(path.exists()); + + let loaded = AliasStore::load(temp.path()).expect("load should succeed"); + assert_eq!( + loaded.get("work").map(|entry| entry.profile.as_str()), + Some("profiles/work.json") + ); +} + +#[test] +fn append_rejects_duplicate_name() { + let mut store = AliasStore::default(); + store + .append("work".to_string(), "profiles/work.json".to_string()) + .expect("first append should succeed"); + let err = store + .append("work".to_string(), "profiles/other.json".to_string()) + .expect_err("duplicate append should fail"); + assert!(err.to_string().contains("alias already exists")); +} diff --git a/app/tests/unit/core/config.rs b/app/tests/unit/core/config.rs new file mode 100644 index 0000000..34d15a4 --- /dev/null +++ b/app/tests/unit/core/config.rs @@ -0,0 +1,137 @@ +use super::*; +use tempfile::TempDir; + +fn base_cli() -> CliInput { + CliInput { + profile: None, + output_mode: OutputMode::Shell, + strict: false, + log_level: LevelFilter::WARN, + log_format: LogFormat::Text, + command: Vec::new(), + } +} + +#[test] +fn default_profile_uses_runseal_home() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + let profiles = runseal_home.join("profiles"); + std::fs::create_dir_all(&profiles).expect("profiles dir should be created"); + std::fs::write(profiles.join("default.json"), "{\"injections\":[]}") + .expect("default profile should be written"); + + let cfg = RuntimeConfig::from_cli_and_env( + base_cli(), + RawEnv { + home: Some(PathBuf::from("/Users/tester")), + runseal_home: Some(runseal_home.clone()), + runseal_resource_home: None, + }, + ) + .expect("config should build"); + + assert_eq!(cfg.runseal_home, runseal_home); + assert_eq!( + cfg.profile_path, + temp.path().join("runseal-home/profiles/default.json") + ); +} + +#[test] +fn profile_flag_overrides_default_resolution() { + let temp = TempDir::new().expect("temp dir should be created"); + let explicit = temp.path().join("explicit.json"); + std::fs::write(&explicit, "{\"injections\":[]}").expect("profile should be written"); + + let mut cli = base_cli(); + cli.profile = Some(explicit.clone()); + + let cfg = RuntimeConfig::from_cli_and_env( + cli, + RawEnv { + home: Some(PathBuf::from("/Users/tester")), + runseal_home: None, + runseal_resource_home: None, + }, + ) + .expect("config should build"); + + assert_eq!(cfg.profile_path, explicit); +} + +#[test] +fn resource_home_defaults_from_runseal_home() { + let temp = TempDir::new().expect("temp dir should be created"); + let runseal_home = temp.path().join("runseal-home"); + std::fs::create_dir_all(runseal_home.join("profiles")).expect("profiles dir should exist"); + std::fs::write( + runseal_home.join("profiles/default.json"), + "{\"injections\":[]}", + ) + .expect("default profile should be written"); + + let cfg = RuntimeConfig::from_cli_and_env( + base_cli(), + RawEnv { + home: Some(PathBuf::from("/Users/tester")), + runseal_home: Some(runseal_home.clone()), + runseal_resource_home: None, + }, + ) + .expect("config should build"); + + assert_eq!(cfg.resource_home, runseal_home.join("resources")); +} + +#[test] +fn missing_default_profile_returns_actionable_error() { + let err = RuntimeConfig::from_cli_and_env( + base_cli(), + RawEnv { + home: Some(PathBuf::from("/Users/tester")), + runseal_home: Some(PathBuf::from("/tmp/does-not-exist")), + runseal_resource_home: None, + }, + ) + .expect_err("missing default profile should fail"); + assert!(err.to_string().contains("profiles/default.json")); +} + +#[test] +fn missing_home_and_runseal_home_fails() { + let err = RuntimeConfig::from_cli_and_env( + base_cli(), + RawEnv { + home: None, + runseal_home: None, + runseal_resource_home: None, + }, + ) + .expect_err("missing home should fail"); + assert!(err.to_string().contains("HOME is not set")); +} + +#[test] +fn empty_runseal_home_is_treated_as_unset() { + let temp = TempDir::new().expect("temp dir should be created"); + let home = temp.path().join("home"); + std::fs::create_dir_all(home.join(".runseal/profiles")).expect("profiles dir should exist"); + std::fs::write( + home.join(".runseal/profiles/default.json"), + "{\"injections\":[]}", + ) + .expect("default profile should be written"); + + let cfg = RuntimeConfig::from_cli_and_env( + base_cli(), + RawEnv { + home: Some(home.clone()), + runseal_home: Some(PathBuf::new()), + runseal_resource_home: None, + }, + ) + .expect("config should fall back to HOME/.runseal"); + + assert_eq!(cfg.runseal_home, home.join(".runseal")); +} diff --git a/app/tests/unit/core/injections/command.rs b/app/tests/unit/core/injections/command.rs new file mode 100644 index 0000000..b55d0c1 --- /dev/null +++ b/app/tests/unit/core/injections/command.rs @@ -0,0 +1,61 @@ +use std::collections::BTreeMap; + +use super::*; + +struct MockEnv { + vars: BTreeMap, +} + +impl EnvReader for MockEnv { + fn var(&self, key: &str) -> Option { + self.vars.get(key).cloned() + } +} + +#[test] +fn parse_export_and_plain_assignment() { + let env = MockEnv { + vars: BTreeMap::new(), + }; + let vars = parse_exports("export A='1'\nB=2\nignored line\n", &env); + assert_eq!( + vars, + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()) + ] + ); +} + +#[test] +fn parse_fnm_style_path_value() { + let env = MockEnv { + vars: BTreeMap::from([("RUNSEAL_TEST_PATH".to_string(), "/usr/bin:/bin".to_string())]), + }; + let vars = parse_exports( + "export PATH=\"/tmp/fnm/bin\":\"$RUNSEAL_TEST_PATH\"\n", + &env, + ); + assert_eq!( + vars, + vec![("PATH".to_string(), "/tmp/fnm/bin:/usr/bin:/bin".to_string())] + ); +} + +#[test] +fn preserve_inner_quotes_when_normalizing() { + let env = MockEnv { + vars: BTreeMap::new(), + }; + let vars = parse_exports("export A='x\"y\"z'\n", &env); + assert_eq!(vars, vec![("A".to_string(), "x\"y\"z".to_string())]); +} + +#[test] +fn skip_invalid_env_keys_from_command_output() { + let env = MockEnv { + vars: BTreeMap::new(), + }; + let vars = parse_exports("export BAD-KEY=1\nexport _GOOD=2\n", &env); + assert_eq!(vars, vec![("_GOOD".to_string(), "2".to_string())]); +} diff --git a/app/tests/unit/core/injections/env.rs b/app/tests/unit/core/injections/env.rs new file mode 100644 index 0000000..6f0770d --- /dev/null +++ b/app/tests/unit/core/injections/env.rs @@ -0,0 +1,182 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::*; +use crate::core::app::{AppContext, CommandRunner, EnvReader}; +use crate::core::config::{LogFormat, OutputMode, RuntimeConfig}; +use tracing_subscriber::filter::LevelFilter; + +struct TestEnv { + vars: BTreeMap, +} + +impl EnvReader for TestEnv { + fn var(&self, key: &str) -> Option { + self.vars.get(key).cloned() + } +} + +struct TestRunner; + +impl CommandRunner for TestRunner { + fn output(&self, program: &str, args: &[String]) -> Result { + std::process::Command::new(program) + .args(args) + .output() + .map_err(Into::into) + } +} + +struct TestApp { + cfg: RuntimeConfig, + env: TestEnv, + runner: TestRunner, +} + +impl TestApp { + fn new(resource_home: &str, vars: BTreeMap) -> Self { + Self { + cfg: RuntimeConfig { + profile_path: PathBuf::from("/tmp/unused.json"), + output_mode: OutputMode::Shell, + strict: false, + log_level: LevelFilter::WARN, + log_format: LogFormat::Text, + command: None, + runseal_home: PathBuf::from("/tmp/runseal-home"), + resource_home: PathBuf::from(resource_home), + }, + env: TestEnv { vars }, + runner: TestRunner, + } + } +} + +impl AppContext for TestApp { + fn config(&self) -> &RuntimeConfig { + &self.cfg + } + + fn env(&self) -> &dyn EnvReader { + &self.env + } + + fn command_runner(&self) -> &dyn CommandRunner { + &self.runner + } +} + +#[test] +fn rejects_empty_env_key() { + let mut vars = BTreeMap::new(); + vars.insert(" ".to_string(), "x".to_string()); + let injection = EnvInjection::new(EnvProfile { + enabled: true, + vars, + ops: Vec::new(), + }); + let err = injection.validate().expect_err("empty key should fail"); + assert!(err.to_string().contains("env var key must not be empty")); +} + +#[test] +fn prepend_path_with_dedup() { + let mut vars = BTreeMap::new(); + vars.insert("PATH".to_string(), "/usr/bin:/bin".to_string()); + let injection = EnvInjection::new(EnvProfile { + enabled: true, + vars, + ops: vec![EnvOpProfile::Prepend { + key: "PATH".to_string(), + value: "/custom/bin:/usr/bin".to_string(), + separator: Some("os".to_string()), + dedup: true, + }], + }); + let app = TestApp::new("/tmp/runseal-res", BTreeMap::new()); + + let exports = injection.export(&app).expect("export should pass"); + let path = exports + .into_iter() + .find(|(k, _)| k == "PATH") + .map(|(_, v)| v) + .expect("PATH should exist"); + assert_eq!(path, "/custom/bin:/usr/bin:/bin"); +} + +#[test] +fn set_if_absent_uses_current_env() { + let key = "RUNSEAL_TEST_SET_IF_ABSENT"; + let app = TestApp::new( + "/tmp/runseal-res", + BTreeMap::from([(key.to_string(), "present".to_string())]), + ); + let injection = EnvInjection::new(EnvProfile { + enabled: true, + vars: BTreeMap::new(), + ops: vec![EnvOpProfile::SetIfAbsent { + key: key.to_string(), + value: "fallback".to_string(), + }], + }); + let exports = injection.export(&app).expect("export should pass"); + assert!(!exports.iter().any(|(k, _)| k == key)); +} + +#[test] +fn resolves_resource_uri_with_default_home() { + let resolved = resolve_resource_refs( + "resource://kubeconfig/xx.yaml", + std::path::Path::new("/tmp/runseal-res"), + ) + .expect("resource path should resolve"); + assert_eq!(resolved, "/tmp/runseal-res/kubeconfig/xx.yaml"); +} + +#[test] +fn resolves_multiple_resource_uris_in_one_value() { + let resolved = resolve_resource_refs( + "resource://kubeconfig/xx.yaml:resource://kubeconfig/yy.yaml", + std::path::Path::new("/tmp/runseal-res"), + ) + .expect("multiple resource paths should resolve"); + assert_eq!( + resolved, + "/tmp/runseal-res/kubeconfig/xx.yaml:/tmp/runseal-res/kubeconfig/yy.yaml" + ); +} + +#[test] +fn resolves_resource_content_uri() { + let temp = tempfile::tempdir().expect("temp dir should exist"); + let dir = temp.path().join("opencode"); + std::fs::create_dir_all(&dir).expect("resource dir should exist"); + let cfg = dir.join("alpha.json"); + std::fs::write(&cfg, "{\"default_agent\":\"alpha\"}") + .expect("resource content should be written"); + + let resolved = resolve_resource_refs("resource-content://opencode/alpha.json", temp.path()) + .expect("resource content should resolve"); + assert_eq!(resolved, "{\"default_agent\":\"alpha\"}"); +} + +#[test] +fn resolves_resource_content_followed_by_separator() { + let temp = tempfile::tempdir().expect("temp dir should exist"); + std::fs::write(temp.path().join("token.txt"), "ALPHA_ONLY") + .expect("resource content should be written"); + + let resolved = resolve_resource_refs("A=resource-content://token.txt;B=1", temp.path()) + .expect("resource content with separator should resolve"); + assert_eq!(resolved, "A=ALPHA_ONLY;B=1"); +} + +#[test] +fn errors_when_resource_content_missing() { + let err = resolve_resource_refs( + "resource-content://missing.json", + std::path::Path::new("/tmp/runseal-res"), + ) + .expect_err("missing content file should fail"); + assert!(err.to_string().contains("failed to read resource content")); +} diff --git a/app/tests/unit/core/injections/mod.rs b/app/tests/unit/core/injections/mod.rs new file mode 100644 index 0000000..685f593 --- /dev/null +++ b/app/tests/unit/core/injections/mod.rs @@ -0,0 +1,180 @@ +use super::*; +use crate::core::app::{AppContext, CommandRunner, EnvReader}; +use crate::core::config::{LogFormat, OutputMode, RuntimeConfig}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use tempfile::TempDir; +use tracing_subscriber::filter::LevelFilter; + +struct TestEnv; + +impl EnvReader for TestEnv { + fn var(&self, _key: &str) -> Option { + None + } +} + +struct TestRunner; + +impl CommandRunner for TestRunner { + fn output(&self, program: &str, args: &[String]) -> Result { + std::process::Command::new(program) + .args(args) + .output() + .map_err(Into::into) + } +} + +struct TestApp { + cfg: RuntimeConfig, + env: TestEnv, + runner: TestRunner, +} + +impl TestApp { + fn new() -> Self { + Self { + cfg: RuntimeConfig { + profile_path: PathBuf::from("/tmp/unused.json"), + output_mode: OutputMode::Shell, + strict: false, + log_level: LevelFilter::WARN, + log_format: LogFormat::Text, + command: None, + runseal_home: PathBuf::from("/tmp/runseal-home"), + resource_home: PathBuf::from("/tmp/runseal-res"), + }, + env: TestEnv, + runner: TestRunner, + } + } +} + +impl AppContext for TestApp { + fn config(&self) -> &RuntimeConfig { + &self.cfg + } + + fn env(&self) -> &dyn EnvReader { + &self.env + } + + fn command_runner(&self) -> &dyn CommandRunner { + &self.runner + } +} + +#[test] +fn skip_disabled_env_injection() { + let specs = vec![ + InjectionProfile::Env(crate::core::profile::EnvProfile { + enabled: false, + vars: BTreeMap::from([("A".to_string(), "1".to_string())]), + ops: Vec::new(), + }), + InjectionProfile::Env(crate::core::profile::EnvProfile { + enabled: true, + vars: BTreeMap::from([("B".to_string(), "2".to_string())]), + ops: Vec::new(), + }), + ]; + + let app = TestApp::new(); + let exports = execute_lifecycle(&app, specs).expect("lifecycle should pass"); + assert_eq!(exports.len(), 1); + assert!(exports.contains(&("B".to_string(), "2".to_string()))); +} + +#[test] +fn fail_validation_when_env_key_is_empty() { + let specs = vec![InjectionProfile::Env(crate::core::profile::EnvProfile { + enabled: true, + vars: BTreeMap::from([(" ".to_string(), "1".to_string())]), + ops: Vec::new(), + })]; + + let app = TestApp::new(); + let err = execute_lifecycle(&app, specs).expect_err("empty env key should fail"); + assert!(err.to_string().contains("validation failed")); +} + +#[test] +fn command_injection_exports_values() { + let specs = vec![InjectionProfile::Command( + crate::core::profile::CommandProfile { + enabled: true, + program: "bash".to_string(), + args: vec![ + "-lc".to_string(), + "printf \"export CMD_A='1'\\nCMD_B=2\\n\"".to_string(), + ], + }, + )]; + + let app = TestApp::new(); + let exports = execute_lifecycle(&app, specs).expect("command lifecycle should pass"); + assert!(exports.contains(&("CMD_A".to_string(), "1".to_string()))); + assert!(exports.contains(&("CMD_B".to_string(), "2".to_string()))); +} + +#[test] +fn command_injection_observes_prior_exports() { + let specs = vec![ + InjectionProfile::Env(crate::core::profile::EnvProfile { + enabled: true, + vars: BTreeMap::from([("BASE".to_string(), "seed".to_string())]), + ops: Vec::new(), + }), + InjectionProfile::Command(crate::core::profile::CommandProfile { + enabled: true, + program: "bash".to_string(), + args: vec![ + "-lc".to_string(), + "printf 'export DERIVED=${BASE}-ok\\n'".to_string(), + ], + }), + ]; + + let app = TestApp::new(); + let exports = execute_lifecycle(&app, specs).expect("command should see prior exports"); + assert!(exports.contains(&("BASE".to_string(), "seed".to_string()))); + assert!(exports.contains(&("DERIVED".to_string(), "seed-ok".to_string()))); +} + +#[test] +fn register_failure_rolls_back_prior_registered_injections() { + let temp = TempDir::new().expect("temp dir should be created"); + let source_a = temp.path().join("source-a"); + let source_b = temp.path().join("source-b"); + let target_a = temp.path().join("target-a"); + let target_b = temp.path().join("target-b"); + + std::fs::write(&source_a, "a").expect("source-a should exist"); + std::fs::write(&source_b, "b").expect("source-b should exist"); + std::fs::write(&target_b, "occupied").expect("target-b should exist"); + + let specs = vec![ + InjectionProfile::Symlink(crate::core::profile::SymlinkProfile { + enabled: true, + source: source_a.clone(), + target: target_a.clone(), + on_exist: crate::core::profile::SymlinkOnExist::Error, + cleanup: true, + }), + InjectionProfile::Symlink(crate::core::profile::SymlinkProfile { + enabled: true, + source: source_b, + target: target_b, + on_exist: crate::core::profile::SymlinkOnExist::Error, + cleanup: true, + }), + ]; + + let app = TestApp::new(); + let err = execute_lifecycle(&app, specs).expect_err("second register should fail"); + assert!(err.to_string().contains("registration failed")); + assert!( + std::fs::symlink_metadata(&target_a).is_err(), + "first symlink should be rolled back on later register failure" + ); +} diff --git a/app/tests/unit/core/injections/symlink.rs b/app/tests/unit/core/injections/symlink.rs new file mode 100644 index 0000000..1390ed8 --- /dev/null +++ b/app/tests/unit/core/injections/symlink.rs @@ -0,0 +1,79 @@ +use super::*; +use tempfile::TempDir; + +#[test] +fn register_fails_when_target_exists_in_error_mode() { + let temp = TempDir::new().expect("temp dir should be created"); + let source = temp.path().join("source.md"); + let target = temp.path().join("AGENTS.md"); + std::fs::write(&source, "content").expect("source file should be created"); + std::fs::write(&target, "existing").expect("target file should be created"); + + let injection = SymlinkInjection::new(SymlinkProfile { + enabled: true, + source: source.clone(), + target: target.clone(), + on_exist: SymlinkOnExist::Error, + cleanup: true, + }); + + let err = injection + .register_at(&source, &target, SymlinkOnExist::Error) + .expect_err("existing target should fail"); + assert!( + err.to_string() + .contains("refusing to overwrite existing file") + ); +} + +#[test] +fn register_and_shutdown_manage_symlink() { + let temp = TempDir::new().expect("temp dir should be created"); + let source = temp.path().join("source.md"); + let target = temp.path().join(".codex/AGENTS.md"); + std::fs::write(&source, "content").expect("source file should be created"); + + let mut injection = SymlinkInjection::new(SymlinkProfile { + enabled: true, + source: source.clone(), + target: target.clone(), + on_exist: SymlinkOnExist::Error, + cleanup: true, + }); + + injection + .register() + .expect("register should create symlink"); + + let metadata = std::fs::symlink_metadata(&target).expect("symlink should exist"); + assert!(metadata.file_type().is_symlink()); + + injection + .shutdown() + .expect("shutdown should remove symlink"); + assert!( + std::fs::symlink_metadata(&target).is_err(), + "symlink should be removed" + ); +} + +#[test] +fn replace_mode_replaces_existing_file() { + let temp = TempDir::new().expect("temp dir should be created"); + let source = temp.path().join("source.md"); + let target = temp.path().join("AGENTS.md"); + std::fs::write(&source, "content").expect("source file should be created"); + std::fs::write(&target, "existing").expect("target file should be created"); + + let mut injection = SymlinkInjection::new(SymlinkProfile { + enabled: true, + source: source.clone(), + target: target.clone(), + on_exist: SymlinkOnExist::Replace, + cleanup: true, + }); + + injection.register().expect("replace mode should succeed"); + let metadata = std::fs::symlink_metadata(&target).expect("target should exist"); + assert!(metadata.file_type().is_symlink()); +} diff --git a/app/tests/unit/core/profile.rs b/app/tests/unit/core/profile.rs new file mode 100644 index 0000000..d72a1d4 --- /dev/null +++ b/app/tests/unit/core/profile.rs @@ -0,0 +1,93 @@ +use super::*; + +#[test] +fn parse_injections_with_defaults() { + let raw = r#" + { + "injections": [ + { "type": "env", "vars": { "A": "1", "B": "2" } }, + { "type": "symlink", "source": "./fixtures/agents.md", "target": "~/.codex/AGENTS.md" } + ] + }"#; + + let profile: Profile = serde_json::from_str(raw).expect("profile should parse"); + assert_eq!(profile.injections.len(), 2); + + match &profile.injections[0] { + InjectionProfile::Env(env) => { + assert!(env.enabled); + assert_eq!(env.vars.get("A"), Some(&"1".to_string())); + assert_eq!(env.vars.get("B"), Some(&"2".to_string())); + assert!(env.ops.is_empty()); + } + _ => panic!("expected env injection"), + } + match &profile.injections[1] { + InjectionProfile::Symlink(link) => { + assert!(link.enabled); + assert!(matches!(link.on_exist, SymlinkOnExist::Error)); + assert!(link.cleanup); + } + _ => panic!("expected symlink injection"), + } +} + +#[test] +fn reject_unknown_injection_type() { + let raw = r#" + { + "injections": [ + { "type": "python" } + ] + }"#; + + let err = serde_json::from_str::(raw).expect_err("unknown type should fail"); + let msg = err.to_string(); + assert!(msg.contains("unknown variant")); +} + +#[test] +fn parse_env_ops() { + let raw = r#" + { + "injections": [ + { + "type": "env", + "vars": { "A": "1" }, + "ops": [ + { "op": "prepend", "key": "PATH", "value": "/opt/bin", "separator": "os", "dedup": true }, + { "op": "set_if_absent", "key": "NPM_CONFIG_REGISTRY", "value": "https://registry.npmjs.org/" } + ] + } + ] + }"#; + + let profile: Profile = serde_json::from_str(raw).expect("profile should parse"); + match &profile.injections[0] { + InjectionProfile::Env(env) => { + assert_eq!(env.vars.get("A"), Some(&"1".to_string())); + assert_eq!(env.ops.len(), 2); + } + _ => panic!("expected env injection"), + } +} + +#[test] +fn parse_command_injection() { + let raw = r#" + { + "injections": [ + { "type": "command", "program": "fnm", "args": ["env", "--shell", "bash"] } + ] + }"#; + + let profile: Profile = serde_json::from_str(raw).expect("profile should parse"); + match &profile.injections[0] { + InjectionProfile::Command(cmd) => { + assert!(cmd.enabled); + assert_eq!(cmd.program, "fnm"); + assert_eq!(cmd.args, vec!["env", "--shell", "bash"]); + } + _ => panic!("expected command injection"), + } +} diff --git a/app/tests/unit/core/runtime.rs b/app/tests/unit/core/runtime.rs new file mode 100644 index 0000000..1c9c1d4 --- /dev/null +++ b/app/tests/unit/core/runtime.rs @@ -0,0 +1,41 @@ +use super::*; + +#[test] +fn escape_single_quotes_for_shell() { + assert_eq!(shell_single_quote_escape("a'b"), "a'\"'\"'b"); +} + +#[test] +fn env_map_keeps_last_value_for_duplicate_keys() { + let map = to_env_map( + vec![ + ("A".to_string(), "1".to_string()), + ("B".to_string(), "2".to_string()), + ("A".to_string(), "3".to_string()), + ], + false, + ) + .expect("non-strict mode should allow duplicate keys"); + assert_eq!(map.get("A"), Some(&"3".to_string())); + assert_eq!(map.get("B"), Some(&"2".to_string())); +} + +#[test] +fn env_map_rejects_duplicate_keys_in_strict_mode() { + let err = to_env_map( + vec![ + ("A".to_string(), "1".to_string()), + ("A".to_string(), "2".to_string()), + ], + true, + ) + .expect_err("strict mode should reject duplicate keys"); + assert!(err.to_string().contains("duplicate exported key")); +} + +#[test] +fn env_map_rejects_invalid_key() { + let err = to_env_map(vec![("BAD-KEY".to_string(), "1".to_string())], false) + .expect_err("invalid env key should fail"); + assert!(err.to_string().contains("invalid exported key")); +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3e56d98 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + node-helper: + build: + context: . + dockerfile: docker/node-helper.Dockerfile + platform: linux/amd64 + working_dir: /workspace/runseal + volumes: + - .:/workspace/runseal + tty: true + stdin_open: true + + builder: + image: rust:1.91-bookworm + platform: linux/amd64 + working_dir: /workspace/runseal + volumes: + - .:/workspace/runseal + tty: true + stdin_open: true + command: + - bash + - -lc + - sleep infinity diff --git a/docker/node-helper.Dockerfile b/docker/node-helper.Dockerfile new file mode 100644 index 0000000..8a24ab0 --- /dev/null +++ b/docker/node-helper.Dockerfile @@ -0,0 +1,17 @@ +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + python3 \ + tar \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace/runseal + +CMD ["bash", "-lc", "sleep infinity"] diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 1ab5cf4..a9b4d02 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -3,12 +3,12 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const BASE = "/envlock/"; +const BASE = process.env.RUNSEAL_DOCS_BASE ?? "/"; const THIS_DIR = fileURLToPath(new URL(".", import.meta.url)); const ROOT_FAVICON = readFileSync(resolve(THIS_DIR, "../public/favicon.ico")); const rootFaviconPlugin = { - name: "envlock-root-favicon", + name: "runseal-root-favicon", configureServer(server: { middlewares: { use: (path: string, handler: (_req: unknown, res: { setHeader: (name: string, value: string) => void; end: (buffer: Buffer) => void; }) => void) => void; }; }) { server.middlewares.use("/favicon.ico", (_req, res) => { res.setHeader("Content-Type", "image/x-icon"); @@ -24,8 +24,8 @@ const rootFaviconPlugin = { }; export default defineConfig({ - title: "envlock", - description: "Deterministic environment sessions from JSON profiles.", + title: "runseal", + description: "Seal the run.", base: BASE, head: [ ["link", { rel: "icon", type: "image/svg+xml", href: `${BASE}favicon.svg` }], @@ -36,13 +36,13 @@ export default defineConfig({ ["meta", { name: "author", content: "PerishCode" }], ["meta", { name: "copyright", content: "Copyright © 2026 PerishCode" }], ["meta", { name: "agent:owner", content: "PerishCode" }], - ["meta", { name: "agent:project", content: "envlock" }], + ["meta", { name: "agent:project", content: "runseal" }], ["meta", { name: "agent:contract:version", content: "1" }], [ "meta", { name: "agent:index:v1", - content: "agent:contract:version,agent:mode,agent:entry:install,agent:entry:cli,agent:entry:ci,agent:resolution,agent:locale:default,agent:locale:source,agent:locale:policy" + content: "agent:contract:version,agent:mode,agent:entry:install,agent:entry:use,agent:entry:scoreboard,agent:resolution,agent:locale:default,agent:locale:source,agent:locale:policy" } ], ["meta", { name: "agent:mode", content: "meta-first" }], @@ -60,15 +60,15 @@ export default defineConfig({ [ "meta", { - name: "agent:entry:cli", - content: `${BASE}reference/cli` + name: "agent:entry:use", + content: `${BASE}how-to/use-profiles` } ], [ "meta", { - name: "agent:entry:ci", - content: `${BASE}how-to/ci-integration` + name: "agent:entry:scoreboard", + content: `${BASE}explanation/runseal-score/native` } ] ], @@ -89,22 +89,24 @@ export default defineConfig({ lang: "en-US", label: "English", link: "/", - title: "envlock", - description: "Deterministic environment sessions from JSON profiles.", + title: "runseal", + description: "Seal the run.", themeConfig: { nav: [ - { text: "Tutorial", link: "/tutorials/quick-start" }, - { text: "How-to", link: "/how-to/install" }, - { text: "Reference", link: "/reference/cli" }, - { text: "Explanation", link: "/explanation/design-boundaries" }, - { text: "GitHub", link: "https://github.com/PerishCode/envlock" } + { text: "Install", link: "/how-to/install" }, + { text: "Use", link: "/how-to/use-profiles" }, + { text: "Posts", link: "/posts/what-is-runseal" }, + { text: ":node/", link: "/node/01-making-npm-i-g-pnpm-sealable" }, + { text: "FAQ", link: "/explanation/faq" }, + { text: "Scoreboard", link: "/explanation/runseal-score/native" }, + { text: "GitHub", link: "https://github.com/PerishCode/runseal" } ], outline: { level: [2, 3], label: "On this page" }, editLink: { - pattern: "https://github.com/PerishCode/envlock/edit/main/docs/:path", + pattern: "https://github.com/PerishCode/runseal/edit/main/docs/:path", text: "Edit this page on GitHub" }, localeLinks: { @@ -116,22 +118,24 @@ export default defineConfig({ lang: "zh-CN", label: "简体中文", link: "/zh-CN/", - title: "envlock", - description: "通过 JSON 配置实现可复现环境会话。", + title: "runseal", + description: "Seal the run.", themeConfig: { nav: [ - { text: "教程", link: "/zh-CN/tutorials/quick-start" }, - { text: "操作指南", link: "/zh-CN/how-to/install" }, - { text: "参考", link: "/zh-CN/reference/cli" }, - { text: "说明", link: "/zh-CN/explanation/faq" }, - { text: "GitHub", link: "https://github.com/PerishCode/envlock" } + { text: "安装", link: "/zh-CN/how-to/install" }, + { text: "使用", link: "/zh-CN/how-to/use-profiles" }, + { text: "Posts", link: "/zh-CN/posts/what-is-runseal" }, + { text: ":node/", link: "/zh-CN/node/01-making-npm-i-g-pnpm-sealable" }, + { text: "FAQ", link: "/zh-CN/explanation/faq" }, + { text: "Scoreboard", link: "/zh-CN/explanation/runseal-score/native" }, + { text: "GitHub", link: "https://github.com/PerishCode/runseal" } ], outline: { level: [2, 3], label: "本页导航" }, editLink: { - pattern: "https://github.com/PerishCode/envlock/edit/main/docs/:path", + pattern: "https://github.com/PerishCode/runseal/edit/main/docs/:path", text: "在 GitHub 上编辑此页" }, localeLinks: { @@ -146,109 +150,75 @@ export default defineConfig({ sidebar: { "/": [ { - text: "Tutorial", + text: "Docs", items: [ - { text: "Quick Start", link: "/tutorials/quick-start" }, - { text: "First-Star Trigger", link: "/tutorials/first-star-trigger" } + { text: "Install", link: "/how-to/install" }, + { text: "Use Profiles", link: "/how-to/use-profiles" }, + { text: "FAQ", link: "/explanation/faq" } ] }, { - text: "How-to", + text: "Scoreboard", items: [ - { text: "Install", link: "/how-to/install" }, - { text: "Common Recipes", link: "/how-to/common-recipes" }, - { text: "Migrate to v0.3", link: "/how-to/migrate-to-v0.3" }, - { text: "Use Profiles", link: "/how-to/use-profiles" }, - { text: "Run Command Mode", link: "/how-to/command-mode" }, - { text: "CI Integration", link: "/how-to/ci-integration" }, - { text: "Release Validation", link: "/how-to/release-validation" }, - { text: "Release Operator Playbook", link: "/how-to/release-operator-playbook" }, - { text: "Update and Uninstall", link: "/how-to/update-and-uninstall" }, - { text: "Docs Maintenance", link: "/how-to/docs-maintenance" } + { text: "L4 Native", link: "/explanation/runseal-score/native" }, + { text: "L3 Good", link: "/explanation/runseal-score/good" }, + { text: "L2 Normal", link: "/explanation/runseal-score/normal" }, + { text: "L1 Other", link: "/explanation/runseal-score/other" } ] }, { - text: "Reference", + text: "Posts", items: [ - { text: "Quick Reference", link: "/reference/quick-reference" }, - { text: "CLI", link: "/reference/cli" }, - { text: "Profile Format", link: "/reference/profile" }, - { text: "Environment Variables", link: "/reference/environment" }, - { text: "Changelog", link: "/changelog" }, - { text: "Release Pipeline", link: "/reference/release" }, - { text: "Agent Meta Contract", link: "/reference/agent-meta-contract" }, - { text: "Agent Cold-Start Checklist", link: "/reference/agent-coldstart-checklist" } + { text: "What is runseal?", link: "/posts/what-is-runseal" }, + { text: "How We Want to Build runseal", link: "/posts/how-we-want-to-build-runseal" }, + { text: "Why We Want to Build runseal", link: "/posts/why-we-want-to-build-runseal" } ] }, { - text: "Explanation", + text: ":node/", items: [ - { text: "Why envlock", link: "/explanation/why-envlock" }, - { text: "FAQ", link: "/explanation/faq" }, - { text: "Design Boundaries", link: "/explanation/design-boundaries" }, - { text: "Troubleshooting", link: "/explanation/troubleshooting" }, - { text: "Support Policy", link: "/explanation/support-policy" }, - { text: "Language Maintenance", link: "/explanation/language-maintenance" }, - { text: "First-Star Observability", link: "/explanation/first-star-observability" }, - { text: "GEO Index", link: "/explanation/geo-index" }, - { text: "envlock-score/native", link: "/explanation/envlock-score/native" }, - { text: "envlock-score/good", link: "/explanation/envlock-score/good" }, - { text: "envlock-score/normal", link: "/explanation/envlock-score/normal" }, - { text: "envlock-score/other", link: "/explanation/envlock-score/other" } + { text: "01 | Making npm i -g pnpm Sealable", link: "/node/01-making-npm-i-g-pnpm-sealable" }, + { text: "02 | What :node Still Needs to Prove", link: "/node/02-what-node-still-needs-to-prove" }, + { text: "03 | Where Corepack Fits", link: "/node/03-where-corepack-fits" }, + { text: "04 | Which Boundaries Are Not Ours", link: "/node/04-which-boundaries-are-not-ours" }, + { text: "05 | Which Node Surface Wins", link: "/node/05-which-node-surface-wins" } ] } ], "/zh-CN/": [ { - text: "教程", + text: "文档", items: [ - { text: "快速开始", link: "/zh-CN/tutorials/quick-start" }, - { text: "首星触发页", link: "/zh-CN/tutorials/first-star-trigger" } + { text: "安装", link: "/zh-CN/how-to/install" }, + { text: "使用 Profiles", link: "/zh-CN/how-to/use-profiles" }, + { text: "FAQ", link: "/zh-CN/explanation/faq" } ] }, { - text: "操作指南", + text: "Scoreboard", items: [ - { text: "安装", link: "/zh-CN/how-to/install" }, - { text: "常见用法", link: "/zh-CN/how-to/common-recipes" }, - { text: "迁移到 v0.3", link: "/zh-CN/how-to/migrate-to-v0.3" }, - { text: "使用 Profiles", link: "/zh-CN/how-to/use-profiles" }, - { text: "子命令模式", link: "/zh-CN/how-to/command-mode" }, - { text: "CI 集成", link: "/zh-CN/how-to/ci-integration" }, - { text: "发布验证", link: "/zh-CN/how-to/release-validation" }, - { text: "发布操作指南", link: "/zh-CN/how-to/release-operator-playbook" }, - { text: "更新与卸载", link: "/zh-CN/how-to/update-and-uninstall" }, - { text: "文档维护", link: "/zh-CN/how-to/docs-maintenance" }, + { text: "L4 Native", link: "/zh-CN/explanation/runseal-score/native" }, + { text: "L3 Good", link: "/zh-CN/explanation/runseal-score/good" }, + { text: "L2 Normal", link: "/zh-CN/explanation/runseal-score/normal" }, + { text: "L1 Other", link: "/zh-CN/explanation/runseal-score/other" } ] }, { - text: "参考", + text: "Posts", items: [ - { text: "快速参考", link: "/zh-CN/reference/quick-reference" }, - { text: "CLI 参考", link: "/zh-CN/reference/cli" }, - { text: "Profile 格式", link: "/zh-CN/reference/profile" }, - { text: "环境变量", link: "/zh-CN/reference/environment" }, - { text: "变更记录", link: "/zh-CN/changelog" }, - { text: "发布流水线", link: "/zh-CN/reference/release" }, - { text: "Agent Meta 契约", link: "/zh-CN/reference/agent-meta-contract" }, - { text: "Agent 冷启动检查清单", link: "/zh-CN/reference/agent-coldstart-checklist" } + { text: "What is runseal?", link: "/zh-CN/posts/what-is-runseal" }, + { text: "How We Want to Build runseal", link: "/zh-CN/posts/how-we-want-to-build-runseal" }, + { text: "Why We Want to Build runseal", link: "/zh-CN/posts/why-we-want-to-build-runseal" } ] }, { - text: "说明", + text: ":node/", items: [ - { text: "为什么选择 envlock", link: "/zh-CN/explanation/why-envlock" }, - { text: "常见问题", link: "/zh-CN/explanation/faq" }, - { text: "设计边界", link: "/zh-CN/explanation/design-boundaries" }, - { text: "故障排查", link: "/zh-CN/explanation/troubleshooting" }, - { text: "支持策略", link: "/zh-CN/explanation/support-policy" }, - { text: "语言维护", link: "/zh-CN/explanation/language-maintenance" }, - { text: "首星观测", link: "/zh-CN/explanation/first-star-observability" }, - { text: "GEO 指数", link: "/zh-CN/explanation/geo-index" }, - { text: "envlock-score/native", link: "/zh-CN/explanation/envlock-score/native" }, - { text: "envlock-score/good", link: "/zh-CN/explanation/envlock-score/good" }, - { text: "envlock-score/normal", link: "/zh-CN/explanation/envlock-score/normal" }, - { text: "envlock-score/other", link: "/zh-CN/explanation/envlock-score/other" } + { text: "01 | Making npm i -g pnpm Sealable", link: "/zh-CN/node/01-making-npm-i-g-pnpm-sealable" }, + { text: "02 | What :node Still Needs to Prove", link: "/zh-CN/node/02-what-node-still-needs-to-prove" }, + { text: "03 | Where Corepack Fits", link: "/zh-CN/node/03-where-corepack-fits" }, + { text: "04 | Which Boundaries Are Not Ours", link: "/zh-CN/node/04-which-boundaries-are-not-ours" }, + { text: "05 | Which Node Surface Wins", link: "/zh-CN/node/05-which-node-surface-wins" } ] } ] @@ -256,6 +226,6 @@ export default defineConfig({ search: { provider: "local" }, - socialLinks: [{ icon: "github", link: "https://github.com/PerishCode/envlock" }] + socialLinks: [{ icon: "github", link: "https://github.com/PerishCode/runseal" }] } }); diff --git a/docs/.vitepress/theme/components/GithubStars.vue b/docs/.vitepress/theme/components/GithubStars.vue index 3c262f8..ccb7b34 100644 --- a/docs/.vitepress/theme/components/GithubStars.vue +++ b/docs/.vitepress/theme/components/GithubStars.vue @@ -2,7 +2,7 @@ import { onMounted, ref } from "vue"; const stars = ref("..."); -const CACHE_KEY = "envlock_github_stars_cache_v1"; +const CACHE_KEY = "runseal_github_stars_cache_v1"; const CACHE_TTL_MS = 30 * 60 * 1000; function formatStars(value: number): string { @@ -33,7 +33,7 @@ onMounted(async () => { } try { - const response = await fetch("https://api.github.com/repos/PerishCode/envlock", { + const response = await fetch("https://api.github.com/repos/PerishCode/runseal", { headers: { Accept: "application/vnd.github+json" } }); if (!response.ok) { @@ -64,7 +64,7 @@ onMounted(async () => {