diff --git a/.github/branch-protection/main.json b/.github/branch-protection/main.json index a136f7f..8bdac78 100644 --- a/.github/branch-protection/main.json +++ b/.github/branch-protection/main.json @@ -5,10 +5,44 @@ "deletion (blocks branch deletion)", "non_fast_forward (blocks force-push)", "required_signatures (requires verified commits)", - "required_linear_history (blocks merge commits)" + "required_linear_history (blocks merge commits)", + "pull_request (requires squash-only PRs and resolved review threads)", + "required_status_checks (requires the exact strict CI contexts below)" ], "allow_force_pushes": false, "allow_deletions": false, + "repository_settings": { + "allow_squash_merge": true, + "allow_merge_commit": false, + "allow_rebase_merge": false, + "squash_merge_commit_title": "PR_TITLE", + "squash_merge_commit_message": "BLANK", + "allow_update_branch": true, + "delete_branch_on_merge": true, + "has_wiki": false, + "has_projects": false + }, + "pull_request_policy": { + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "require_code_owner_review": false, + "require_last_push_approval": false, + "dismiss_stale_reviews_on_push": false, + "allowed_merge_methods": [ + "squash" + ] + }, + "required_status_checks": { + "strict": true, + "do_not_enforce_on_create": false, + "contexts": [ + "actionlint / actionlint", + "CodeQL / CodeQL (actions)", + "Dependency review / Dependency review", + "Secret scan / Gitleaks secret scan", + "zizmor (SARIF) / zizmor (SARIF)" + ] + }, "release_governance": { "semver_tag_ruleset_id": 18733591, "protected_tag_patterns": [ @@ -26,9 +60,11 @@ "This file documents the ACTUAL branch protection applied via gh API ruleset.", "Solo-maintainer repo: no PR review requirement (self-approval not possible on GitHub).", "Repository administrators retain the standard emergency bypass used across NDDev repositories.", + "Repository merges are squash-only, use the pull-request title as the commit title, and omit an auto-generated body.", + "Update-branch is enabled, merged branches are deleted automatically, and unused wiki/projects surfaces are disabled.", "SemVer tags cannot be updated, force-pushed, or deleted after creation.", "Repository-level release immutability applies to releases created after it was enabled; release 2.0.0 predates that setting and remains protected by its immutable tag.", - "Secret scanning, push protection, and Dependabot security updates are enabled at repo level.", - "To add PR reviews later (when collaborators join), add a 'pull_request' rule to the ruleset." + "Secret scanning, push protection, dependency graph, Dependabot alerts, and Dependabot security updates are enabled at repo level.", + "The pull-request rule intentionally requires zero approvals for the solo maintainer while still requiring resolved review threads; increase the approval count when collaborators join." ] } diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 64ce7df..5139a4b 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -7,11 +7,13 @@ against a pinned submodule revision. Reusable workflows are sourced from [`NDDev-it-com/nddev-ci-workflows`](https://github.com/NDDev-it-com/nddev-ci-workflows) -release `0.3.0`, pinned to commit -`1acba687361415b3f5942acceb40b228bb0387fd`. +release `0.5.0`, pinned to commit +`d1bb39730a45bae1e8c507477bb15c5e05a2b412`. ## Workflows +- **`actionlint.yml`** validates every public workflow's syntax and expressions + through the checksum-verified shared `actionlint.yml` workflow. - **`codeql.yml`** runs CodeQL for GitHub Actions on pushes, pull requests, and the weekly schedule through `public-codeql.yml`. - **`dependency-review.yml`** checks pull-request dependency changes through @@ -20,10 +22,19 @@ release `0.3.0`, pinned to commit `secret-scan.yml`. - **`scorecard.yml`** runs the public OpenSSF Scorecard JSON workflow on pushes and the weekly schedule. -- **`release.yml`** verifies a SemVer tag against all three build-version - sources, checks core-plugin version parity, extracts the matching changelog - section, and publishes the GitHub Release. -- **`labeler.yml`** labels pull requests from `.github/labeler.yml`. +- **`zizmor.yml`** performs pedantic GitHub Actions security analysis, publishes + SARIF to code scanning, and fails on low-or-higher findings. GitHub permits + code-scanning result upload for workflows triggered by `pull_request`, + including fork and Dependabot pull requests. +- **`release.yml`** requires strict SemVer equality across the tag, every build + contract, and both core-plugin version sources. The tagged commit must be an + ancestor of freshly fetched `origin/main`. It then calls the shared + supply-chain workflow to publish one immutable release with a deterministic + source archive (including the root secret-boundary `.gitignore`), checksums, + SPDX SBOM, build provenance, and SBOM attestation. +- **`labeler.yml`** labels pull requests from `.github/labeler.yml` without + checking out contributor code. Its hardened runner uses minimal egress and + per-pull-request concurrency. All external actions and reusable workflows are pinned to immutable commit SHAs. The private harness validates those pins and the public/private repository diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..0497cad --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,22 @@ +name: actionlint + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: actionlint-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + actionlint: + name: actionlint + permissions: + contents: read + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/actionlint.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 38d12f7..bc52d1f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -8,10 +8,7 @@ on: schedule: - cron: "20 7 * * 1" -permissions: - contents: read - security-events: write - actions: read +permissions: {} concurrency: group: codeql-${{ github.ref }} @@ -20,7 +17,11 @@ concurrency: jobs: codeql: name: CodeQL - uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/public-codeql.yml@1acba687361415b3f5942acceb40b228bb0387fd # 0.3.0 + permissions: + contents: read + security-events: write # Publish CodeQL analysis to code scanning. + actions: read # Read workflow metadata for the actions language. + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/public-codeql.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 with: languages: '["actions"]' queries: security-and-quality diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 290dbe4..a3e8bb3 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -4,8 +4,7 @@ on: pull_request: branches: [main] -permissions: - contents: read +permissions: {} concurrency: group: dependency-review-${{ github.ref }} @@ -14,4 +13,7 @@ concurrency: jobs: dependency-review: name: Dependency review - uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/public-dependency-review.yml@1acba687361415b3f5942acceb40b228bb0387fd # 0.3.0 + permissions: + contents: read + pull-requests: write # Comment when dependency policy rejects a change. + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/public-dependency-review.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 581b666..4fc1bea 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,15 +1,15 @@ name: labeler on: - pull_request_target: + # Required to label pull requests from forks. No contributor-controlled code + # is checked out or executed by this privileged workflow. + pull_request_target: # zizmor: ignore[dangerous-triggers] types: [opened, synchronize, reopened] -permissions: - contents: read - pull-requests: write +permissions: {} concurrency: - group: labeler-${{ github.ref }} + group: labeler-pr-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: @@ -17,9 +17,18 @@ jobs: name: Label PR runs-on: ubuntu-latest timeout-minutes: 5 + permissions: + contents: read + pull-requests: write # Apply labels to the triggering pull request only. steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + agent.api.stepsecurity.io:443 + api.github.com:443 + - name: Apply labels uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 550b9f8..5e3817a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,72 +4,145 @@ on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]+-pre" -permissions: - contents: write +permissions: {} concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: - release: - name: Publish GitHub Release + verify: + name: Verify release contract runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + agent.api.stepsecurity.io:443 + api.github.com:443 + github.com:443 + codeload.github.com:443 + objects.githubusercontent.com:443 + - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false - name: Verify tag and version contracts + env: + RELEASE_SHA: ${{ github.sha }} + RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail - tag="${GITHUB_REF_NAME}" - version="$(cat VERSION)" - version_json="$(python3 -c 'import json; print(json.load(open("build/version.json"))["build_version"])')" - manifest_json="$(python3 -c 'import json; print(json.load(open("build/manifest.json"))["build_version"])')" - marketplace_plugin="$(python3 -c 'import json; plugins=json.load(open("zcode_tools/marketplaces/nddev-builder/marketplace.json"))["plugins"]; print(next(plugin["version"] for plugin in plugins if plugin["name"] == "core"))')" - plugin_manifest="$(python3 -c 'import json; print(json.load(open("zcode_tools/marketplaces/nddev-builder/plugins/core/.zcode-plugin/plugin.json"))["version"])')" - if [ "$tag" != "$version" ] || [ "$version" != "$version_json" ] || [ "$version" != "$manifest_json" ]; then - echo "::error::Tag and build versions differ: tag=$tag VERSION=$version version.json=$version_json manifest.json=$manifest_json" - exit 1 - fi - if [ "$marketplace_plugin" != "$plugin_manifest" ]; then - echo "::error::Core plugin versions differ: marketplace=$marketplace_plugin plugin.json=$plugin_manifest" - exit 1 - fi - echo "ok: tag and build versions match $version" - echo "ok: core plugin versions match $plugin_manifest" + python3 -I <<'PY' + import json + import os + import pathlib + import re + import sys - - name: Extract changelog section - id: changelog - run: | - set -euo pipefail - tag="${GITHUB_REF_NAME}" - # Extract the matching ## [TAG] section from CHANGELOG.md (up to the next ##). - # Match the heading prefix (## [TAG] may be followed by " - YYYY-MM-DD"). - body=$(awk -v tag="## [${tag}]" ' - index($0, tag) == 1 { found=1; next } - found && /^## \[/ { exit } - found { print } - ' CHANGELOG.md | sed '/^[[:space:]]*$/d') - if [ -z "$body" ]; then - body="Release $tag. See CHANGELOG.md for details." + semver = re.compile( + r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)" + r"(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?" + ) + + def fail(message): + print(f"::error::{message}", file=sys.stderr) + raise SystemExit(1) + + def load_json(path): + try: + with pathlib.Path(path).open(encoding="utf-8") as stream: + value = json.load(stream) + except (OSError, json.JSONDecodeError) as exc: + fail(f"cannot read valid JSON from {path}: {exc}") + if not isinstance(value, dict): + fail(f"{path} must contain a JSON object") + return value + + raw_version = pathlib.Path("VERSION").read_bytes() + if ( + not raw_version.endswith(b"\n") + or raw_version.count(b"\n") != 1 + or b"\r" in raw_version + ): + fail("VERSION must contain exactly one LF-terminated SemVer line") + try: + version_file = raw_version[:-1].decode("ascii") + except UnicodeDecodeError: + fail("VERSION must contain ASCII SemVer only") + + version_json = load_json("build/version.json").get("build_version") + manifest_json = load_json("build/manifest.json").get("build_version") + marketplace_path = "zcode_tools/marketplaces/nddev-builder/marketplace.json" + marketplace = load_json(marketplace_path) + plugins = marketplace.get("plugins") + if not isinstance(plugins, list): + fail(f"{marketplace_path}.plugins must be an array") + core_entries = [ + plugin for plugin in plugins + if isinstance(plugin, dict) and plugin.get("name") == "core" + ] + if len(core_entries) != 1: + fail(f"{marketplace_path} must declare exactly one core plugin") + marketplace_plugin = core_entries[0].get("version") + plugin_path = ( + "zcode_tools/marketplaces/nddev-builder/plugins/core/" + ".zcode-plugin/plugin.json" + ) + plugin_manifest = load_json(plugin_path).get("version") + + versions = { + "release tag": os.environ["RELEASE_TAG"], + "VERSION": version_file, + "build/version.json": version_json, + "build/manifest.json": manifest_json, + "nddev-builder marketplace core": marketplace_plugin, + "nddev-builder core plugin": plugin_manifest, + } + invalid = [ + name for name, value in versions.items() + if not isinstance(value, str) or semver.fullmatch(value) is None + ] + if invalid: + fail("invalid strict SemVer value(s): " + ", ".join(invalid)) + if len(set(versions.values())) != 1: + details = ", ".join(f"{name}={value}" for name, value in versions.items()) + fail("release versions differ: " + details) + print(f"ok: all release contracts match {version_file}") + PY + + git fetch --no-tags --force origin \ + '+refs/heads/main:refs/remotes/origin/main' + tag_commit="$(git rev-parse --verify "${RELEASE_SHA}^{commit}")" + if ! git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main; then + echo "::error::Tagged commit $tag_commit is not reachable from fetched origin/main" + exit 1 fi - { - echo "body<> "$GITHUB_OUTPUT" + echo "ok: tagged commit $tag_commit is an ancestor of fetched origin/main" - - name: Create GitHub Release - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 - with: - name: ${{ github.ref_name }} - body: ${{ steps.changelog.outputs.body }} - draft: false - prerelease: ${{ contains(github.ref_name, '-pre') }} - generate_release_notes: false + publish: + name: Publish attested immutable release + needs: verify + permissions: + contents: write # Create the immutable release and its exact assets. + id-token: write # Exchange workflow identity for attestations. + attestations: write # Publish provenance and SBOM attestations. + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/release-supply-chain.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 + with: + version: ${{ github.ref_name }} + package_name: nddev-zcode-app + archive_paths: >- + README.md LICENSE NOTICE VERSION CHANGELOG.md SECURITY.md SUPPORT.md + CODE_OF_CONDUCT.md CONTRIBUTING.md .gitignore .github build cli-tools + config docs references zcode_tools diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index adec91b..2e19db9 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -6,10 +6,7 @@ on: schedule: - cron: "40 7 * * 1" -permissions: - contents: read - actions: read - id-token: write +permissions: {} concurrency: group: scorecard-${{github.ref}} @@ -18,4 +15,8 @@ concurrency: jobs: scorecard: name: OpenSSF Scorecard - uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/public-scorecard-json.yml@1acba687361415b3f5942acceb40b228bb0387fd # 0.3.0 + permissions: + contents: read + actions: read # Inspect workflow metadata for Scorecard checks. + id-token: write # Obtain OIDC identity for the Scorecard artifact. + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/public-scorecard-json.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index fae85b8..8b8fd4b 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -16,4 +16,4 @@ concurrency: jobs: secret-scan: name: Secret scan - uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/secret-scan.yml@1acba687361415b3f5942acceb40b228bb0387fd # 0.3.0 + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/secret-scan.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..dd385fa --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,27 @@ +name: zizmor + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "50 7 * * 1" + workflow_dispatch: + +permissions: {} + +concurrency: + group: zizmor-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + zizmor: + name: zizmor (SARIF) + permissions: + contents: read + security-events: write # Publish workflow-analysis results to code scanning. + uses: NDDev-it-com/nddev-ci-workflows/.github/workflows/zizmor-sarif.yml@d1bb39730a45bae1e8c507477bb15c5e05a2b412 # 0.5.0 + with: + persona: pedantic + min_severity: low diff --git a/CHANGELOG.md b/CHANGELOG.md index b02658b..a5ee798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,112 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [2.0.1] - 2026-07-10 + ### Security -- Require verified signatures and linear history on `main`, protect SemVer tags - from update or deletion, and enable immutable releases for future +- Required verified signatures and linear history on `main`, protected SemVer + tags from update or deletion, and enabled immutable releases for future publications. +- Pinned every ZCode 3.3.3 distribution artifact by exact filename, byte size, + and SHA-512, with macOS signing identity and Debian package identity recorded + for platform-native verification. +- Verified HTTPS-only download redirects, DMG/Gatekeeper/code-signing identity, + exact DEB control metadata and package-owned CLI paths, and strict app/CLI + postconditions before bootstrap can report success. +- Restricted bootstrap to the exact canonical ZCode CDN base and the verified + DEB CLI path `/opt/ZCode/resources/glm/zcode.cjs`; DEB apply now requires a + successful privileged `dpkg --dry-run -i` before the real transaction. +- Added private pre-install DEB payload extraction: the exact CLI path, safe + file shape, and pinned CLI version must pass before dpkg runs. The installed + dpkg-owned CLI must then be byte-identical by SHA-512 to that verified entry. +- Added deterministic app/launcher bootstrap locks and rollback-protected swaps. + Exact postconditions define the commit point; incomplete post-commit cleanup + reports failure without discarding verified committed state. +- Revalidated the 2.0.1 artifact pins against repeated current CDN downloads. + Bootstrap now fails closed on digest drift even when byte size is unchanged; + a pin is accepted only after SHA-512 and native identity are reconciled. +- Replaced the release publisher with the SHA-pinned shared supply-chain + workflow: 2.0.1 publishes an immutable source archive, SHA256 manifest, SPDX + SBOM, build-provenance attestation, and SBOM attestation after local version + parity succeeds. +- Required strict SemVer equality across the tag, all module build-version + sources, and both core-plugin version sources; the tagged commit must be an + ancestor of freshly fetched `origin/main` before publication. +- Hardened the privileged pull-request labeler before any action runs, removed + source checkout, allowlisted only required egress, and isolated cancellation + by pull-request number. +- Added always-on actionlint and fork-safe pedantic zizmor gates through the + exact shared CI release, with low-or-higher workflow-security findings fatal. +- Replaced live-tree mutation with private same-filesystem staging, full + pre-commit verification, exclusive target and backup-pool locks, held rotation + slots, fsync, atomic rename, and rollback of both the target and displaced + slot. +- Replaced the marker-only guard with a validated `BUILD-VERSION` schema, + canonical/disjoint path containment, rejection of symlinks/special + files/hardlink aliases, and explicit typed envelopes for reversible adoption + of an unstamped target. +- Corrected the source/runtime secret boundary: rendered `.env`, provider and + MCP configs, `v2/credentials.json`, certificates, and backups are explicitly + classified as sensitive and owner-only. +- Required `build/.env` to be a current-user-owned regular non-symlink with mode + `0600` or stricter. Parsing remains non-evaluating, with narrow HOME-prefix + expansion limited to target and backup path keys. +- Made plan and apply reject missing or empty placeholders in keys or values + across active config, setting, provider, MCP, and hook branches; only + explicitly disabled provider/MCP nodes may remain dormant. +- Replaced extension-authoring guidance to shell-source `.env` files with an + approved process-environment contract and non-evaluating, allowlisted parsing. + +### Changed + +- Bumped the public build and `nddev-builder/core` component to 2.0.1; + upgraded the artifact metadata contract to schema 2. +- Standardized module, managed-stamp, adoption-envelope, and backup-name version + parsing on SemVer 2.0.0 rules instead of permissive SemVer-like patterns. +- Upgraded the public product contract to version 2 and distinguished plugin + `mcpServers` inputs from the installed `mcp.servers` configuration path. +- Made `nddev-designer` and `nddev-developer` production-ready minimal profiles + with substantive role-specific instructions. Their empty global extension + surfaces are intentional: project tools and policy come from the active + workspace. +- Kept Z.ai account OAuth as the verified ZCode 3.3.3 preference while defining + explicit Z.ai API-key access as a separate, disabled-by-default custom + provider at `https://api.z.ai/api/anthropic`. BigModel remains a separate, + disabled-by-default provider on `https://open.bigmodel.cn/api/anthropic`. +- Hardened repository governance to squash-only merges using pull-request + titles, branch-update support, automatic deletion of merged branches, and + disabled wiki/projects surfaces. + +### Fixed + +- Enabled `core@nddev-builder` by default so the advertised builder toolkit is + active after installation. +- Started `recentProjects` as an empty portable list instead of shipping a + workstation-specific repository path. +- Repaired the malformed Markdown fence in the `add-mcp-server` skill and + aligned provider/tool authoring guidance with the runtime secret contract. +- Removed unsourced, volatile numeric token-cost claims and replaced absolute + zero-cost language with the durable routing-metadata/on-demand context model. +- Used a verified, locally extracted AppImage on Ubuntu only when the complete + dpkg toolchain is absent; DEB installation failures now remain fatal instead + of producing a false-success dependency-repair path. +- Preferred current `diskutil image attach`/`eject` operations on macOS while + retaining deprecated `hdiutil` mount operations only as a compatibility + fallback; DMG checksum verification still requires `hdiutil verify`. +- Required explicit `--adopt-unmanaged` plus an explicit existing `--target` + before replacing unstamped state; adopted backups are target-bound and + relocation requires a second explicit flag. +- Rejected recognized but command-inapplicable installer flags and mutually + exclusive apply/plan modes instead of silently ignoring user intent. +- Bounded live CLI version probing to one canonical executable, 3 seconds, and + 64 KiB. Probe failure is nonfatal advisory `unknown` for normal install and a + strict postcondition failure for bootstrap. + +### Removed + +- Removed unused tracked `.gitkeep` files for the dead `build/system` and + `cli-tools/templates` platform scaffolds. ## [2.0.0] - 2026-07-10 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70edd23..cf1629c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,11 +23,10 @@ its full validation and platform matrix before merging or releasing changes. ## Local setup -Required tools are Git, Bash, and Python 3. A local ZCode installation is not +Required tools are Git, Bash, and Python 3.10+. A local ZCode installation is not required for plan-mode validation. ```bash -cp build/.env.example build/.env cli-tools/scripts/install.sh list cli-tools/scripts/install.sh install \ --marketplace nddev-builder --platform macos --plan @@ -51,13 +50,16 @@ use `--apply` against a real target merely to validate a pull request. - Keep repository artifacts in English. - Never commit credentials, tokens, cookies, private keys, `build/.env`, runtime state, caches, or generated ZCode output. +- Treat an installed target and its backups as sensitive: never print, attach, + or commit `.env`, rendered provider/MCP configs, `v2/credentials.json`, + certificates, or backup contents. - Preserve ZCode convention discovery: plugin components live under `skills/`, `commands/`, `agents/`, and `references/`; metadata stays in `.zcode-plugin/plugin.json`. -- Keep the build version identical in `VERSION`, `build/version.json`, and - `build/manifest.json`. -- Keep the `nddev-builder/core` plugin version identical in its marketplace - entry and `.zcode-plugin/plugin.json`. +- For a release, use one strict SemVer in `VERSION`, the `build_version` fields + in `build/version.json` and `build/manifest.json`, the `nddev-builder` + marketplace `core` entry, and the core `.zcode-plugin/plugin.json` manifest. + The release workflow rejects drift. - Add a `CHANGELOG.md` entry for release behavior changes. - Treat backup, restore, remove, target resolution, and plan purity as safety contracts. Changes to them require regression coverage in the private harness. diff --git a/README.md b/README.md index 1882146..0f6df89 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ changes. - **Author:** Danil Silantyev (github:rldyourmnd), CEO NDDev - **License:** AGPL-3.0-or-later -- **Build version:** 2.0.0 +- **Build version:** 2.0.1 - **Verified ZCode runtime:** app 3.3.3, CLI 0.15.0, model GLM-5.2 ## What this repository contains @@ -18,7 +18,8 @@ This public repository contains only the distributable implementation: ```text zcode_tools/ Marketplace sources: complete editable ZCode setups cli-tools/ Installer and lifecycle commands for macOS and Ubuntu -build/ Version contract, manifest, system files, and secret template +build/ Version, artifact-integrity, manifest, and secret contracts +config/ Public product and native-format contract metadata references/ Public ZCode compatibility baseline docs/ Public architecture, installation, and secrets documentation ``` @@ -34,8 +35,10 @@ boundaries. ## Quick start ```bash -# Populate local secrets. build/.env is gitignored. +# Optional: configure explicit API-key providers or custom local settings. +# Z.ai account OAuth remains the default; build/.env is gitignored. cp build/.env.example build/.env +chmod 600 build/.env $EDITOR build/.env # Install the pinned ZCode app and CLI, if needed. @@ -49,18 +52,30 @@ cli-tools/scripts/install.sh install --marketplace nddev-builder --apply ``` Plan mode performs no writes and does not invoke a locally installed `zcode` -binary. Apply mode checks the live runtime version, then performs the requested -lifecycle operation. See [docs/install.md](docs/install.md) for install, update, -switch, backup, restore, remove, and custom-target usage. +binary. It still parses and merges config, setting, provider, MCP, and hook +inputs and rejects unresolved active placeholders in keys or values. Setup +installation in apply mode performs a bounded live runtime probe before it +changes the target. +See [docs/install.md](docs/install.md) for install, update, switch, backup, +restore, remove, and custom-target usage. ## Available setups Each directory under `zcode_tools/marketplaces/` is a complete setup: -- `nddev-builder` provides a reusable 13-skill, 13-command toolkit for creating - and managing ZCode marketplaces, plugins, and components. -- `nddev-designer` is the designer-oriented setup. -- `nddev-developer` is the software-development setup. +- `nddev-builder` enables its reusable 13-skill, 13-command `core` toolkit for + creating and managing ZCode marketplaces, plugins, and components. +- `nddev-designer` is a deliberately lean product-design profile focused on + design-system consistency, accessibility, responsive states, and + implementation-ready decisions. +- `nddev-developer` is a deliberately lean software-engineering profile focused + on root-cause changes, explicit contracts, proportionate verification, and + clean delivery. + +The designer and developer profiles intentionally ship without global plugins, +hooks, MCP servers, or user-scope components. They take project-specific tools +and policy from the active workspace, which keeps the profiles portable and +their permanent context surface small. The installer copies exactly one selected marketplace into the target ZCode home. Marketplace content is ordinary source and can be adapted independently. @@ -78,8 +93,29 @@ selectively restored during an update or switch. Logs, crash data, and plugin caches are regenerated. Destructive restore/remove operations refuse targets that are not marked by this installer with `BUILD-VERSION`. +Install also refuses an existing unstamped target unless the user supplies both +`--adopt-unmanaged` and an explicit existing `--target`. Adoption stores the +original content tree in a typed, target-bound backup envelope that the guarded +restore lifecycle can recover. Its filesystem permissions are normalized to +private owner-only modes during adoption. Rendering and verification happen in +a private sibling stage; exclusive target and backup-pool locks plus +identity-bound commit rollback prevent partial live installs and rotation races. + ## Secrets `build/.env.example` defines supported keys; the gitignored `build/.env` holds -local values. `${VAR_NAME}` placeholders in marketplace templates are rendered -only during installation. See [docs/secrets.md](docs/secrets.md). +optional local values for explicit API-key providers and integrations. It must +be a current-user-owned regular non-symlink with mode `0600` or stricter. The +installer never evaluates it as shell code; only the two documented target-path +keys support a narrow leading `$HOME`/`${HOME}` expansion. + +`${VAR_NAME}` placeholders in marketplace JSON values are rendered during plan +validation and installation; placeholder-bearing object keys are rejected. +Missing or empty placeholders in active config/setting/provider/MCP/hook +branches fail closed. Explicitly disabled provider/MCP entries may stay dormant. + +The installed ZCode home contains sensitive runtime data: its `.env`, rendered +provider and MCP configs, `v2/credentials.json`, and backups can contain tokens +or API keys. +Never print, commit, upload, or include them in diagnostic evidence. See +[docs/secrets.md](docs/secrets.md). diff --git a/SECURITY.md b/SECURITY.md index 9118265..72b2760 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,7 +6,7 @@ Security reporting covers the public implementation in this repository: - marketplace sources under `zcode_tools/`, - installer and lifecycle logic under `cli-tools/`, -- version, manifest, system, and secret-template data under `build/`, +- version, artifact-integrity, manifest, and secret-template data under `build/`, - public references, documentation, and GitHub workflows. ## Supported versions @@ -15,7 +15,7 @@ Only the current exact numeric release tag receives security fixes. | Version | Supported | | --- | --- | -| Current exact tag `2.0.0` | yes | +| Current exact tag `2.0.1` | yes | | Older tags | no; upgrade to the current exact tag | ## Reporting a vulnerability @@ -37,8 +37,9 @@ Include: - non-sensitive logs or output, - a suggested fix, if known. -Never include credentials, tokens, cookies, private keys, or sensitive runtime -logs in a report. +Never include credentials, tokens, cookies, private keys, rendered provider or +MCP configs, `.env` files, `v2/credentials.json`, backup contents, or sensitive +runtime logs in a report. ## Response targets @@ -50,18 +51,62 @@ accepted report within 30 business days. These targets are best-effort. - External GitHub Actions and reusable workflows are pinned to full commit SHAs. - Workflow permissions follow least privilege. +- The privileged pull-request labeler does not check out or execute contributor + code. Runner hardening starts first, egress is allowlisted, and concurrency is + isolated per pull-request number. - `main` requires verified signatures and linear history; SemVer tags cannot be updated or deleted. -- Repository-level release immutability is enabled for future releases. -- Generic CodeQL, dependency-review, secret-scan, and Scorecard checks run in - this public repository. +- Repository merges are squash-only with the pull-request title as the commit + title and an empty generated body. Branch updates are allowed, merged branches + are deleted automatically, and unused wiki/projects surfaces are disabled. +- Repository-level release immutability is enabled; numeric releases publish + checksums, an SPDX SBOM, build provenance, and an SBOM attestation through the + SHA-pinned shared supply-chain workflow. +- Release verification requires strict SemVer equality across the tag, module + build contracts, and both core-plugin version sources. The tagged commit must + also be an ancestor of a freshly fetched `origin/main`. +- Generic actionlint, pedantic zizmor SARIF, CodeQL, dependency-review, + secret-scan, and Scorecard checks run in this public repository. - The maintainers' private `nddev-harnesses` control plane validates module boundaries, JSON and shell contracts, plan-mode purity, lifecycle behavior, restore safety, platform behavior, and release consistency. - Secrets are rendered from a local, gitignored `build/.env`; only - `build/.env.example` is tracked. -- Restore and remove refuse existing targets without the installer's - `BUILD-VERSION` marker. + `build/.env.example` is tracked. The real file must be a current-user-owned + regular non-symlink with mode `0600` or stricter. It is parsed without shell + evaluation; only target/backup path keys support narrow leading HOME-prefix + expansion. +- Plan and apply both reject missing or empty placeholders in keys or values + across active config, setting, provider, MCP, and hook branches. Only + explicitly disabled provider/MCP nodes may retain dormant placeholders. +- The installed target and backups are private to the current user; rendered + secret-bearing files and restored credentials use owner-only permissions. +- Downloaded ZCode artifacts are verified against pinned byte sizes and SHA-512 + digests before platform-native package or signing identity checks. The 2.0.1 + pins were revalidated against repeated current CDN downloads; a digest + mismatch is fatal even when the byte size still matches. +- Bootstrap requires the exact canonical CDN base, locks each installer-managed + app endpoint and user launcher, and retains rollback state until strict + postconditions define a commit. Post-commit cleanup failure is visible but + never rolls back verified committed state. +- DEB bootstrap privately extracts the payload and requires one safe + `/opt/ZCode/resources/glm/zcode.cjs` entry with the pinned CLI version before + a successful privileged `dpkg --dry-run -i`. After installation, the exact + dpkg-owned path/version and SHA-512 equality with that verified entry are + required. +- Runtime CLI probing resolves one canonical executable and limits execution to + 3 seconds and 64 KiB of output. Normal install treats probe failure as + advisory unknown state; bootstrap rejects it as a failed postcondition. +- Lifecycle mutations use canonical/disjoint path checks, target and backup-pool + locks, same-filesystem staging, pre-commit verification, atomic rename, and + rollback of both live state and a displaced rotation slot. Managed inputs + reject symlinks, special files, and hardlink aliases, and verified stages are + fsynced before commit. +- Restore and remove require a fully validated managed stamp. Install refuses + an unstamped existing target unless explicit adoption names that target; + adopted state is stored in a typed, target-bound backup envelope. +- Errors and handled signals clean up transaction state. Uncatchable termination + or power loss can leave fail-closed locks or recovery holds for operator + inspection; the installer never silently steals or deletes them. ## Out of scope diff --git a/SUPPORT.md b/SUPPORT.md index 7f85ec3..77577c0 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -15,3 +15,7 @@ When filing an issue, include: - the `--plan` output (or `--apply` output if it failed), - the relevant `BUILD-VERSION` contents, - sanitized logs and minimal repro steps. + +Never attach `.env` files, rendered provider or MCP configs, +`v2/credentials.json`, certificates, or backup contents. Redact tokens, API +keys, paths containing private account information, and other sensitive values. diff --git a/VERSION b/VERSION index 227cea2..38f77a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0 +2.0.1 diff --git a/build/.env.example b/build/.env.example index 5b8910b..151cbc1 100644 --- a/build/.env.example +++ b/build/.env.example @@ -2,29 +2,38 @@ # # Copy this file to build/.env and fill in real values: # cp build/.env.example build/.env +# chmod 600 build/.env # $EDITOR build/.env # # build/.env is gitignored (see .gitignore) and NEVER committed. The installer -# sources it for both the install target directory and secret values. Every -# value below is OPTIONAL — sensible defaults apply when commented out. +# accepts only a current-user-owned regular non-symlink file with no group or +# world permissions (0600 or stricter). It parses key/value data without +# evaluating shell syntax. Existing process environment values take precedence. # # Secret values are injected into config templates via ${VAR_NAME} placeholders -# at install time (render_template). Unknown placeholders are left as-is. +# at install time. Missing or empty values leave a placeholder unresolved. +# Plan and apply both reject unresolved active config, provider, MCP, or hook +# values; only explicitly disabled provider/MCP entries may remain dormant. # ─── Install target ────────────────────────────────────────────────────── # Where the installer builds ~/.zcode. Default is the standard ZCode location. # Override to install into a custom directory (e.g. testing, multi-account). +# Only ZCODE_TARGET and ZCODE_BACKUPS_DIR expand an exact leading literal $HOME, +# $HOME/, ${HOME}, or ${HOME}/ prefix. All other values remain literal; no +# general expansion occurs. # ZCODE_TARGET=$HOME/.zcode # Where backups are stored. Default is ~/.zcode-backups. # ZCODE_BACKUPS_DIR=$HOME/.zcode-backups # ─── Provider secrets (rendered into v2/config.json) ───────────────────── -# Z.ai (GLM) coding-plan provider API key — REQUIRED for the default provider. -# Get yours at https://z.ai (coding plan dashboard). +# Optional Z.ai (GLM) coding-plan API key for the explicit custom provider, +# which is disabled by default. The verified account-mode default remains Z.ai +# OAuth and does not use this value. After adding a key, deliberately set that +# provider's enabled field to true. Get an API key at https://z.ai. ZAI_API_KEY= -# Bigmodel coding-plan provider API key (optional; provider is disabled by +# BigModel coding-plan provider API key (optional; provider is disabled by # default — set enabled:true in v2-config.template.json to use it). BIGMODEL_API_KEY= diff --git a/build/.gitignore b/build/.gitignore index b0013d9..168f290 100644 --- a/build/.gitignore +++ b/build/.gitignore @@ -1,4 +1,4 @@ -# Secrets — never commit. The real values live in build/.env, sourced at install time. +# Secrets — never commit. build/.env is strictly parsed and allowlisted; it is never shell-sourced. .env # Generated build outputs (if any future tooling writes here) diff --git a/build/manifest.json b/build/manifest.json index 35ed7df..9995380 100644 --- a/build/manifest.json +++ b/build/manifest.json @@ -1,23 +1,83 @@ { "name": "nddev-zcode-app", - "build_version": "2.0.0", + "build_version": "2.0.1", "source_root": "zcode_tools/marketplaces/", "installer": "cli-tools/scripts/install.sh --marketplace ", "layout": { "zcode_tools/marketplaces//": "each marketplace is a self-contained ~/.zcode setup (AGENTS.md, config templates, mcp/hooks, skills/commands/agents, plugins)", "cli-tools/": "installer: --marketplace selects one setup and builds ~/.zcode from it (macOS + Ubuntu)", - "build/": "build artifacts: version.json, manifest.json, system files, secrets templates (shared across setups)", + "build/": "build contracts: version.json, manifest.json, and the local secrets template shared across setups", "config/": "public repository and release contract metadata", "references/": "verified public ZCode compatibility baseline", "docs/": "public installation, architecture, and secrets documentation", ".github/": "public security, release, and repository-automation workflows" }, + "artifact_integrity_policy": { + "metadata_source": "build/version.json:zcode_download_artifacts", + "canonical_cdn_base": "https://cdn-zcode.z.ai/zcode/electron/releases", + "required_before_install": [ + "exact filename", + "exact byte size", + "SHA-512 digest" + ], + "macos_identity": "hdiutil checksum, exact Team ID/bundle ID/app version/bundle version, deep/strict code signature, and Gatekeeper source=Notarized Developer ID", + "debian_identity": "before install: exact package metadata plus one safe /opt/ZCode/resources/glm/zcode.cjs payload entry with the pinned CLI version; after install: exact dpkg-owned path/version and byte-identical CLI SHA-512", + "appimage_identity": "exact filename, byte size, SHA-512 digest, embedded CLI version, and installed version marker" + }, + "bootstrap_policy": { + "locking": "deterministically ordered locks protect each managed application endpoint and the user launcher; dpkg retains ownership of its system package transaction", + "debian_preflight": "dpkg --dry-run -i must succeed before the real dpkg -i transaction", + "commit_point": "exact app, package, entrypoint, launcher, and CLI postconditions define a committed bootstrap", + "cleanup_after_commit": "cleanup failure is reported without rolling back a verified committed app or launcher", + "cleanup_before_commit": "errors and handled signals roll back staged app and launcher swaps where recovery state is unambiguous" + }, + "command_option_policy": { + "bootstrap": ["--platform", "--apply", "--plan", "--dry-run"], + "install": ["--marketplace", "--target", "--platform", "--apply", "--plan", "--dry-run", "--adopt-unmanaged"], + "remove": ["--target", "--apply", "--plan", "--dry-run", "--keep-backup"], + "restore": ["--slot", "--target", "--apply", "--plan", "--dry-run", "--allow-target-relocation"], + "list": ["-l", "--list", "--backups"], + "invalid_combination": "unknown, command-inapplicable, and mutually exclusive mode options are rejected instead of ignored" + }, + "runtime_probe_policy": { + "resolution": "resolve and canonicalize one executable before invoking it", + "timeout_seconds": 3, + "max_output_bytes": 65536, + "advisory_failure": "timeout, nonzero exit, excessive output, decode/runtime error, or missing executable becomes unknown/not-installed without aborting install", + "bootstrap_postcondition": "bootstrap remains strict and rejects an unknown or mismatched CLI version" + }, + "version_policy": { + "syntax": "SemVer 2.0.0 for module, runtime, managed-stamp, adopted-envelope, and backup-name versions", + "release_lockstep": [ + "VERSION", + "build/version.json:build_version", + "build/manifest.json:build_version", + "zcode_tools/marketplaces/nddev-builder/marketplace.json:plugins[name=core].version", + "zcode_tools/marketplaces/nddev-builder/plugins/core/.zcode-plugin/plugin.json:version" + ], + "release_lineage": "the tagged commit must be an ancestor of freshly fetched origin/main" + }, "backup_policy": { "location": "~/.zcode-backups/", - "name_format": "--old.zcode (N = slot 0-9; VERSION inside BUILD-VERSION too)", + "name_format": "--old.zcode (N = slot 0-9; VERSION is validated SemVer, or unmanaged only for explicit adoption)", "max_backups": 10, "rotation": "lowest free slot 0-9; oldest (by mtime) overwritten when full, regardless of version", - "version_source": "~/.zcode/BUILD-VERSION" + "version_source": "validated /BUILD-VERSION for managed targets", + "adopted_unmanaged_envelope": "-unmanaged-old.zcode/{NDDEV-BACKUP.json,payload/}" + }, + "transaction_policy": { + "path_boundaries": "canonical non-root target and backup roots must be disjoint absent-or-real-directory endpoints on one filesystem", + "safe_tree": "managed inputs reject symlinks, special files, and multiply-linked regular files", + "locking": "exclusive target lock and exclusive lock for the shared backup pool, acquired in deterministic order", + "staging": "render, restore selected state, normalize permissions, verify, and fsync a same-filesystem sibling stage before live replacement", + "commit": "identity-bind and hold an occupied backup slot, move the previous target into its backup, then atomically rename the verified stage without replacement", + "rollback": "restore only identity-matched previous-target and held-slot state if commit, a handled signal, or pre-commit finalization fails; identity mismatch preserves foreign state, recovery paths, and locks; post-commit housekeeping failure is reported without discarding committed state", + "hard_interruption": "SIGKILL or power loss may leave a stale lock, stage, or recovery hold for operator inspection" + }, + "adoption_policy": { + "authorization": "--adopt-unmanaged requires install with an explicit existing --target", + "backup": "the original unstamped content tree is stored inside a typed adopted-unmanaged envelope with permissions normalized to private owner-only modes", + "restore": "the original target is enforced; relocation requires restore with explicit --target and --allow-target-relocation" }, "restore_policy": { "always_restore": [ @@ -37,6 +97,17 @@ "secrets": { "template": "build/.env.example", "real": "build/.env (gitignored)", - "injection": "${VAR} placeholders in zcode_tools/marketplaces//*.template.json rendered at install time" + "source_requirements": "build/.env must be a current-user-owned regular non-symlink file with no group/world permission bits (0600 or stricter)", + "path_expansion": "only ZCODE_TARGET and ZCODE_BACKUPS_DIR expand an exact leading literal $HOME, $HOME/, ${HOME}, or ${HOME}/ prefix; no shell evaluation or general expansion", + "injection": "${VAR} placeholders in marketplace JSON values are resolved for plan validation and JSON-escaped during apply rendering; placeholder-bearing object keys are rejected", + "active_placeholder_validation": "plan and apply reject missing/empty placeholders in keys or values across active config, setting, provider, MCP, and hook branches; explicitly disabled providers/MCP servers may remain dormant", + "runtime_locations": [ + "/.env", + "/cli/config.json", + "/v2/config.json", + "/v2/credentials.json", + "/" + ], + "permissions": "target and backup trees are private to the current user; rendered secret-bearing files use owner-only permissions" } } diff --git a/build/system/macos/.gitkeep b/build/system/macos/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/build/system/ubuntu/.gitkeep b/build/system/ubuntu/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/build/version.json b/build/version.json index a5728d4..96de560 100644 --- a/build/version.json +++ b/build/version.json @@ -1,22 +1,61 @@ { - "build_version": "2.0.0", + "build_version": "2.0.1", "zcode_app_version": "3.3.3", "zcode_cli_version": "0.15.0", "zcode_runtime": "GLM-5.2", "zcode_cdn_base": "https://cdn-zcode.z.ai/zcode/electron/releases", "zcode_download_artifacts": { - "macos-arm64": "ZCode-3.3.3-mac-arm64.dmg", - "macos-x64": "ZCode-3.3.3-mac-x64.dmg", - "linux-x64-appimage": "ZCode-3.3.3-linux-x64.AppImage", - "linux-x64-deb": "ZCode-3.3.3-linux-x64.deb", - "linux-arm64-appimage": "ZCode-3.3.3-linux-arm64.AppImage", - "linux-arm64-deb": "ZCode-3.3.3-linux-arm64.deb" + "macos-arm64": { + "filename": "ZCode-3.3.3-mac-arm64.dmg", + "size_bytes": 161495574, + "sha512": "5434a525bc03adc274672ad2c9f83d6ebde67ec596ff55d1be0adc426c87731e657a0bf1db637000192491befee37fd34237eaa1b136f8c91b7da38b30fd7078", + "team_id": "8A5X4JJ39T", + "bundle_id": "dev.zcode.app", + "app_version": "3.3.3", + "bundle_version": "3.3.3.2657" + }, + "macos-x64": { + "filename": "ZCode-3.3.3-mac-x64.dmg", + "size_bytes": 169792747, + "sha512": "22a179a23573fdcca08bc2a483ad4ab6df652d6e4af7b51a2ba79b101aad3f4e59aa894617c1bb9cd8a96cce0b93c0d123780400a6bd1d80126b99f83526846a", + "team_id": "8A5X4JJ39T", + "bundle_id": "dev.zcode.app", + "app_version": "3.3.3", + "bundle_version": "3.3.3.2657" + }, + "linux-x64-appimage": { + "filename": "ZCode-3.3.3-linux-x64.AppImage", + "size_bytes": 153884701, + "sha512": "be02e2f39c3cb1347c32e14ccc1888fc6e9937893d55f712ce95e44fdea077bda427a317ae12315c4043fa8a5d0af3e3c4c0263bcb6b8eae5b3a6acf02dfb870" + }, + "linux-x64-deb": { + "filename": "ZCode-3.3.3-linux-x64.deb", + "size_bytes": 113767364, + "sha512": "f71517c2af540cfe884f3417e2be8af72507f9a45cc75e5e3b10436606bba38510d838362721202d701c8cb00d33a2f8869d52d1d1c6097028de9f3843a9a407", + "package_name": "zcode", + "package_arch": "amd64", + "package_version": "3.3.3-2657" + }, + "linux-arm64-appimage": { + "filename": "ZCode-3.3.3-linux-arm64.AppImage", + "size_bytes": 153820859, + "sha512": "ea60d899b88c56ec0740cca4af65317fbb0cdf9a12d12dd7bdf52c3ad2a5dd67aaf6f9299ee4ae90adc4541b9e669db014715054799150dd106f6c2b05cc6647" + }, + "linux-arm64-deb": { + "filename": "ZCode-3.3.3-linux-arm64.deb", + "size_bytes": 107978832, + "sha512": "e36280ecb95edc71df69c5e84f109bacf2fb82aaaa0949666e2268206c7eb3b79af1c0e90afa633c050457326e578f679f4068809f92af307c724952f79bb045", + "package_name": "zcode", + "package_arch": "arm64", + "package_version": "3.3.3-2657" + } }, "zcode_cli_launcher": { "macos_entry": "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", - "linux_entry": "/opt/ZCode/resources/glm/zcode.cjs", + "linux_deb_entry": "/opt/ZCode/resources/glm/zcode.cjs", + "linux_appimage_entry": "${HOME}/.local/opt/ZCode/resources/glm/zcode.cjs", "wrapper_target": "${HOME}/.local/bin/zcode" }, - "schema": 1, - "_comment": "build_version is the semantic version of this nddev-zcode-app build. zcode_app_version and zcode_cli_version pin the exact ZCode desktop app and CLI versions this build was verified against — the installer warns if the running ZCode differs. zcode_cdn_base + zcode_download_artifacts are used by the bootstrap command to download and install the ZCode app. The CLI is the zcode.cjs inside the app bundle, launched via node through a wrapper script." + "schema": 2, + "_comment": "build_version is the semantic version of this nddev-zcode-app build. zcode_app_version and zcode_cli_version pin the exact ZCode desktop app and CLI versions this build was verified against. Every downloadable artifact is pinned by filename, byte size, and SHA-512; platform-native identity metadata is verified before installation where available. The CLI is the zcode.cjs inside the verified app bundle, launched via node through a wrapper script." } diff --git a/cli-tools/scripts/bootstrap.sh b/cli-tools/scripts/bootstrap.sh index bbf1fac..1c949d7 100755 --- a/cli-tools/scripts/bootstrap.sh +++ b/cli-tools/scripts/bootstrap.sh @@ -1,241 +1,1158 @@ #!/usr/bin/env bash -# -# bootstrap.sh — download and install the ZCode desktop app (and wire the CLI -# launcher) on macOS or Ubuntu. This is the "from zero" step: it puts the -# ZCode runtime on the machine so the config installer has something to -# configure. -# -# Reads the pinned version + CDN URLs from build/version.json. -# -# Usage: -# cli-tools/scripts/bootstrap.sh [--platform macos|ubuntu] [--apply|--plan] -# +# Download, verify, install, and post-verify the pinned ZCode desktop runtime. set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" LIB_DIR="$SCRIPT_DIR/lib" - # shellcheck source=lib/common.sh . "$LIB_DIR/common.sh" # shellcheck source=lib/version.sh . "$LIB_DIR/version.sh" -# Load build/.env (may override target, but bootstrap is about the app, not ~/.zcode). -nddev::load_env - APPLY=0 PLATFORM="auto" +TMP_ROOT="" +MOUNT_POINT="" +MOUNTED=0 +MOUNT_ATTEMPTED=0 +MOUNT_TOOL="" +APP_STAGE_PATH="" +APP_STAGE_IDENTITY="" +APP_OLD_PATH="" +APP_OLD_IDENTITY="" +APP_INSTALLED_PATH="" +APP_LIVE_IDENTITY="" +APP_SWAPPED=0 +LAUNCHER_PATH="" +LAUNCHER_OLD_PATH="" +LAUNCHER_OLD_IDENTITY="" +LAUNCHER_STAGE_PATH="" +LAUNCHER_STAGE_IDENTITY="" +LAUNCHER_LIVE_IDENTITY="" +LAUNCHER_SWAPPED=0 +BOOTSTRAP_COMMITTED=0 +BOOTSTRAP_LOCK_PATHS=() +SEEN_APPLY=0 +SEEN_PLAN=0 +SEEN_PLATFORM=0 usage() { cat <<'EOF' Usage: cli-tools/scripts/bootstrap.sh [--platform macos|ubuntu] [--apply|--plan] -Downloads and installs the ZCode desktop app at the version pinned in -build/version.json, then wires the `zcode` CLI launcher into ~/.local/bin. +Installs the exact ZCode desktop artifact pinned in build/version.json and +wires its embedded CLI into ~/.local/bin/zcode. Downloads are accepted only +after size and SHA-512 verification. Options: - --platform macos|ubuntu Target platform (default: auto-detect from uname). - --apply Execute the download + install (default is --plan). - --plan | --dry-run Print actions without writing (default). + --platform macos|ubuntu Target platform (default: auto-detect). + --apply Download, verify, install, and post-verify. + --plan | --dry-run Print the verified plan without writing (default). -h, --help Show this help. -Prerequisites: - - curl (to download) - - node (the CLI launcher runs the app's zcode.cjs through node) - - On macOS: hdiutil + cp (built-in) to mount and copy the .dmg - - On Ubuntu: dpkg (for .deb) OR a writable /opt (for AppImage) +Ubuntu uses the verified DEB when complete dpkg tooling is available. A +verified, locally extracted AppImage is used only when DEB tooling is absent. EOF } while [ "$#" -gt 0 ]; do case "$1" in - --platform) PLATFORM="${2:?--platform requires macos|ubuntu}"; shift 2 ;; - --apply) APPLY=1; shift ;; - --plan|--dry-run) APPLY=0; shift ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown argument: $1" >&2; usage >&2; exit 2 ;; + --platform) + nddev::require_option_once "$SEEN_PLATFORM" "$1" || exit 2 + nddev::require_option_value "$1" "${2-}" || exit 2 + PLATFORM="$2" + SEEN_PLATFORM=1 + shift 2 + ;; + --apply) + nddev::require_option_once "$SEEN_APPLY" "$1" || exit 2 + APPLY=1; SEEN_APPLY=1; shift + ;; + --plan | --dry-run) + nddev::require_option_once "$SEEN_PLAN" "$1" || exit 2 + APPLY=0; SEEN_PLAN=1; shift + ;; + -h | --help) usage; exit 0 ;; + *) nddev::log "error" "unknown argument"; usage >&2; exit 2 ;; esac done - +if [ "$SEEN_APPLY" -eq 1 ] && [ "$SEEN_PLAN" -eq 1 ]; then + nddev::log "error" "--apply and --plan/--dry-run are mutually exclusive" + exit 2 +fi export NDDEV_DRY_RUN=$((1 - APPLY)) if [ "$PLATFORM" = "auto" ]; then PLATFORM="$(nddev::detect_platform)" || exit 1 fi -if [ "$PLATFORM" != "macos" ] && [ "$PLATFORM" != "ubuntu" ]; then - nddev::log "error" "unsupported platform: $PLATFORM (expected macos|ubuntu)" - exit 2 -fi +case "$PLATFORM" in macos | ubuntu) ;; *) nddev::log "error" "unsupported platform"; exit 2 ;; esac -ROOT="$(nddev::repo_root)" -VERSION_JSON="$ROOT/build/version.json" +nddev::require_cmd python3 required || exit 1 +nddev::require_cmd curl required || exit 1 +nddev::require_cmd node required || exit 1 -# Read pinned version and CDN base. -APP_VERSION="$(nddev::pinned_app_version)" -CDN_BASE="$(python3 -c "import json; print(json.load(open('$VERSION_JSON'))['zcode_cdn_base'])")" +nddev::bootstrap_destination() { + local role=$1 path=$2 + python3 -I - "$role" "$path" <<'PY' +import os +import pathlib +import sys -nddev::section "nddev-zcode-app — bootstrap" -nddev::log "info" "mode: $([ "$APPLY" -eq 1 ] && echo 'APPLY' || echo 'PLAN (dry-run)')" -nddev::log "info" "platform: $PLATFORM" -nddev::log "info" "pinned ZCode app: $APP_VERSION" +role, path = sys.argv[1:] +if not path or not os.path.isabs(path) or path == "/" or any(ord(char) < 32 or ord(char) == 127 for char in path): + raise SystemExit(f"{role} must be a non-root absolute path") +parts = pathlib.PurePath(path).parts +if any(part in {".", ".."} for part in parts): + raise SystemExit(f"{role} must not contain dot traversal components") +if os.path.normpath(path) != path or os.path.realpath(path) != path: + raise SystemExit(f"{role} must be canonical and must not traverse symlinked parents") +probe = pathlib.Path(path) +while not probe.exists(): + if probe.parent == probe: + break + probe = probe.parent +if probe.is_symlink() or not probe.is_dir(): + raise SystemExit(f"{role} nearest existing parent must be a real directory") +if os.path.lexists(path) and (os.path.islink(path) or not os.path.isdir(path)): + raise SystemExit(f"{role} must be a real directory or absent") +print(path) +PY +} -# ─── Prerequisites ─────────────────────────────────────────────────────── -nddev::require_cmd curl required || exit 1 -nddev::require_cmd node required || exit 1 +ROOT="$(nddev::repo_root)" +VERSION_JSON="$ROOT/build/version.json" +APP_VERSION="$(nddev::pinned_app_version)" +CLI_VERSION="$(nddev::pinned_cli_version)" -# ─── Detect architecture ───────────────────────────────────────────────── arch="$(uname -m)" case "$arch" in - x86_64|amd64) arch="x64" ;; - arm64|aarch64) arch="arm64" ;; + x86_64 | amd64) arch="x64"; deb_arch="amd64" ;; + arm64 | aarch64) arch="arm64"; deb_arch="arm64" ;; *) nddev::log "error" "unsupported architecture: $arch"; exit 2 ;; esac + +INSTALL_KIND="" +case "$PLATFORM" in + macos) INSTALL_KIND="dmg"; artifact_key="macos-$arch" ;; + ubuntu) + if command -v dpkg >/dev/null 2>&1 \ + && command -v dpkg-deb >/dev/null 2>&1 \ + && command -v dpkg-query >/dev/null 2>&1; then + INSTALL_KIND="deb" + artifact_key="linux-$arch-deb" + else + INSTALL_KIND="appimage" + artifact_key="linux-$arch-appimage" + fi + ;; +esac + +# Load and validate the complete artifact contract in one parse. Tab characters +# are prohibited in all string fields by the validator, making this Bash 3.2 +# compatible assignment unambiguous. +artifact_record="$(python3 -I - "$VERSION_JSON" "$artifact_key" "$APP_VERSION" "$deb_arch" <<'PY' +import json +import re +import sys +from urllib.parse import urlparse + +path, key, expected_app_version, expected_deb_arch = sys.argv[1:] +with open(path, encoding="utf-8") as stream: + data = json.load(stream) +if not isinstance(data, dict) or data.get("schema") != 2: + raise SystemExit("build/version.json requires schema 2") +base = data.get("zcode_cdn_base") +parsed = urlparse(base) if isinstance(base, str) else None +if ( + not parsed + or parsed.scheme != "https" + or parsed.netloc != "cdn-zcode.z.ai" + or parsed.hostname != "cdn-zcode.z.ai" + or parsed.port is not None + or parsed.path != "/zcode/electron/releases" + or parsed.params + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment +): + raise SystemExit( + "zcode_cdn_base must be exactly " + "https://cdn-zcode.z.ai/zcode/electron/releases" + ) +artifacts = data.get("zcode_download_artifacts") +artifact = artifacts.get(key) if isinstance(artifacts, dict) else None +if not isinstance(artifact, dict): + raise SystemExit(f"missing artifact metadata object: {key}") +filename = artifact.get("filename") +digest = artifact.get("sha512") +size = artifact.get("size_bytes") +if not isinstance(filename, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]*", filename): + raise SystemExit(f"invalid artifact filename: {key}") +if "/" in filename or "\\" in filename or any(ord(char) < 32 for char in filename): + raise SystemExit(f"artifact filename must be a basename: {key}") +if not isinstance(digest, str) or not re.fullmatch(r"[0-9a-fA-F]{128}", digest): + raise SystemExit(f"artifact sha512 must be 128 hexadecimal characters: {key}") +if not isinstance(size, int) or isinstance(size, bool) or not 1 <= size <= 2 * 1024**3: + raise SystemExit(f"artifact size_bytes must be between 1 byte and 2 GiB: {key}") + +team_id = bundle_id = bundle_version = package_name = package_arch = package_version = "" +if key.startswith("macos-"): + team_id = artifact.get("team_id") + bundle_id = artifact.get("bundle_id") + bundle_version = artifact.get("bundle_version") + app_version = artifact.get("app_version") + if not isinstance(team_id, str) or not re.fullmatch(r"[A-Z0-9]{10}", team_id): + raise SystemExit(f"invalid macOS team_id: {key}") + if not isinstance(bundle_id, str) or not re.fullmatch(r"[A-Za-z0-9.-]+", bundle_id): + raise SystemExit(f"invalid macOS bundle_id: {key}") + if app_version != expected_app_version: + raise SystemExit(f"macOS artifact app_version mismatch: {key}") + if not isinstance(bundle_version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)+", bundle_version): + raise SystemExit(f"invalid macOS bundle_version: {key}") +elif key.endswith("-deb"): + package_name = artifact.get("package_name") + package_arch = artifact.get("package_arch") + package_version = artifact.get("package_version") + if not isinstance(package_name, str) or not re.fullmatch(r"[a-z0-9][a-z0-9+.-]*", package_name): + raise SystemExit(f"invalid DEB package_name: {key}") + if package_arch != expected_deb_arch: + raise SystemExit(f"DEB package_arch mismatch: {key}") + if not isinstance(package_version, str) or not re.fullmatch(r"[0-9A-Za-z.+:~_-]+", package_version): + raise SystemExit(f"invalid DEB package_version: {key}") + +launcher = data.get("zcode_cli_launcher") +expected_launcher = { + "macos_entry": "/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs", + "linux_deb_entry": "/opt/ZCode/resources/glm/zcode.cjs", + "linux_appimage_entry": "${HOME}/.local/opt/ZCode/resources/glm/zcode.cjs", + "wrapper_target": "${HOME}/.local/bin/zcode", +} +if launcher != expected_launcher: + raise SystemExit("zcode_cli_launcher must match the verified launcher contract exactly") + +fields = ( + base.rstrip("/"), filename, digest.lower(), str(size), team_id, bundle_id, + bundle_version, package_name, package_arch, package_version, + launcher["linux_deb_entry"], +) +if any("|" in field or "\n" in field or "\r" in field for field in fields): + raise SystemExit("artifact metadata contains a forbidden control character") +print("|".join(fields)) +PY +)" || exit 1 +IFS='|' read -r CDN_BASE artifact expected_sha512 expected_size TEAM_ID BUNDLE_ID BUNDLE_VERSION PACKAGE_NAME PACKAGE_ARCH PACKAGE_VERSION DEB_CLI_ENTRY <<< "$artifact_record" +url="${CDN_BASE}/${APP_VERSION}/${artifact}" + +python3 -I - "$url" <<'PY' +import sys +from urllib.parse import urlparse + +parsed = urlparse(sys.argv[1]) +if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment +): + raise SystemExit("artifact URL must remain HTTPS and credential-free") +PY + +nddev::section "nddev-zcode-app — verified bootstrap" +nddev::log "info" "mode: $([ "$APPLY" -eq 1 ] && echo APPLY || echo 'PLAN (dry-run)')" +nddev::log "info" "platform: $PLATFORM" nddev::log "info" "architecture: $arch" +nddev::log "info" "install kind: $INSTALL_KIND" +nddev::log "info" "pinned app/CLI: $APP_VERSION / $CLI_VERSION" +nddev::log "info" "artifact: $artifact ($expected_size bytes)" +nddev::log "info" "sha512: $expected_sha512" + +nddev::release_bootstrap_locks() { + local index lock errors=0 + local -a remaining=() + for ((index=${#BOOTSTRAP_LOCK_PATHS[@]} - 1; index >= 0; index--)); do + lock="${BOOTSTRAP_LOCK_PATHS[$index]}" + if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] release bootstrap lock %q\n' "$lock" + elif [ -d "$lock" ] && [ ! -L "$lock" ]; then + if ! nddev::remove_direct_child_tree "$(dirname "$lock")" "$lock"; then + nddev::log "error" "bootstrap lock cleanup failed; inspect manually: $lock" + remaining+=("$lock") + errors=1 + fi + elif [ -e "$lock" ] || [ -L "$lock" ]; then + nddev::log "error" "bootstrap lock endpoint changed type; inspect manually: $lock" + remaining+=("$lock") + errors=1 + fi + done + BOOTSTRAP_LOCK_PATHS=() + if [ "${#remaining[@]}" -gt 0 ]; then + BOOTSTRAP_LOCK_PATHS=("${remaining[@]}") + fi + return "$errors" +} + +nddev::acquire_bootstrap_locks() { + local lock parent + BOOTSTRAP_LOCK_PATHS=() + while IFS= read -r lock; do + [ -n "$lock" ] || continue + parent="$(dirname "$lock")" + nddev::assert_direct_child "$parent" "$lock" || return 1 + if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] acquire bootstrap lock %q\n' "$lock" + BOOTSTRAP_LOCK_PATHS+=("$lock") + continue + fi + if [ ! -d "$parent" ] || [ -L "$parent" ]; then + nddev::log "error" "bootstrap lock parent must be an existing real directory: $parent" + nddev::release_bootstrap_locks || true + return 1 + fi + if ! mkdir -m 700 "$lock" 2>/dev/null; then + nddev::log "error" "another bootstrap holds or left a stale lock: $lock" + nddev::log "error" "inspect owner metadata manually only after validating $lock/owner" + nddev::log "error" "remove a stale lock only after verifying no bootstrap process is active" + nddev::release_bootstrap_locks || true + return 1 + fi + BOOTSTRAP_LOCK_PATHS+=("$lock") + if ! printf 'pid=%s\nstarted_at=%s\nplatform=%s\n' \ + "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PLATFORM" > "$lock/owner" \ + || ! chmod 600 "$lock/owner"; then + nddev::log "error" "failed to initialize bootstrap lock: $lock" + nddev::release_bootstrap_locks || true + return 1 + fi + done < <(printf '%s\n' "$@" | LC_ALL=C sort -u) +} + +nddev::bootstrap_cleanup() { + local status=$? cleanup_mode=0 cleanup_failed=0 + trap - EXIT INT TERM HUP + [ "$BOOTSTRAP_COMMITTED" -eq 1 ] && cleanup_mode=1 + if [ "$BOOTSTRAP_COMMITTED" -ne 1 ] && [ "$status" -eq 0 ]; then + status=1 + fi + if ! nddev::cleanup_bootstrap_resources "$cleanup_mode"; then + cleanup_failed=1 + nddev::log "error" "bootstrap cleanup is incomplete; inspect the recovery paths reported above" + fi + [ "$status" -ne 0 ] || status=$cleanup_failed + exit "$status" +} + +nddev::detach_image() { + local detached=0 + { [ "$MOUNTED" -eq 1 ] || [ "$MOUNT_ATTEMPTED" -eq 1 ]; } && [ -n "$MOUNT_POINT" ] || return 0 + if [ "$MOUNT_TOOL" = "diskutil" ]; then + if diskutil eject "$MOUNT_POINT" >/dev/null 2>&1 \ + || hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1; then + detached=1 + fi + else + hdiutil detach "$MOUNT_POINT" >/dev/null 2>&1 && detached=1 + fi + if [ "$detached" -ne 1 ] && [ "$MOUNTED" -ne 1 ] \ + && python3 -I -c 'import os,sys; raise SystemExit(0 if not os.path.ismount(sys.argv[1]) else 1)' \ + "$MOUNT_POINT"; then + # A failed attach may leave no mount at all. This is a clean no-op, while + # failure to detach a verified successful mount remains observable. + detached=1 + fi + if [ "$detached" -ne 1 ]; then + nddev::log "error" "failed to detach mounted image; mount is preserved at $MOUNT_POINT" + return 1 + fi + MOUNTED=0 + MOUNT_ATTEMPTED=0 + MOUNT_POINT="" + MOUNT_TOOL="" +} + +nddev::bootstrap_identity_preflight() { + local success=$1 + + # Reconcile the application stage rename if a signal arrived between the + # native rename and the following shell assignment. + if [ -n "$APP_STAGE_PATH" ]; then + if nddev::identity_matches "$APP_STAGE_PATH" directory "$APP_STAGE_IDENTITY"; then + : # Stage rename did not occur. + elif [ -n "$APP_INSTALLED_PATH" ] \ + && nddev::identity_matches "$APP_INSTALLED_PATH" directory "$APP_STAGE_IDENTITY" \ + && { [ ! -e "$APP_STAGE_PATH" ] && [ ! -L "$APP_STAGE_PATH" ]; }; then + APP_LIVE_IDENTITY="$APP_STAGE_IDENTITY" + APP_STAGE_PATH="" + else + nddev::log "error" "application stage identity changed; preserving recovery state and bootstrap locks" + return 1 + fi + fi + + if [ -n "$APP_OLD_PATH" ]; then + if nddev::identity_matches "$APP_OLD_PATH" directory "$APP_OLD_IDENTITY"; then + : + elif [ "$success" -eq 1 ] && [ -n "$APP_LIVE_IDENTITY" ] \ + && { [ ! -e "$APP_OLD_PATH" ] && [ ! -L "$APP_OLD_PATH" ]; }; then + # Re-entered committed cleanup after the old app was deleted. + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + elif [ -z "$APP_LIVE_IDENTITY" ] && [ -n "$APP_STAGE_PATH" ] \ + && nddev::identity_matches "$APP_INSTALLED_PATH" directory "$APP_OLD_IDENTITY" \ + && { [ ! -e "$APP_OLD_PATH" ] && [ ! -L "$APP_OLD_PATH" ]; }; then + # The old-app rename failed before consuming its source. + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + APP_SWAPPED=0 + else + nddev::log "error" "old application identity changed; preserving recovery state and bootstrap locks" + return 1 + fi + fi + + if [ -n "$APP_LIVE_IDENTITY" ]; then + if ! nddev::identity_matches "$APP_INSTALLED_PATH" directory "$APP_LIVE_IDENTITY"; then + nddev::log "error" "installed application identity changed; preserving recovery state and bootstrap locks" + return 1 + fi + elif [ "$APP_SWAPPED" -eq 1 ]; then + if [ -z "$APP_STAGE_PATH" ]; then + nddev::log "error" "application swap state is ambiguous; preserving recovery state and bootstrap locks" + return 1 + fi + if [ -e "$APP_INSTALLED_PATH" ] || [ -L "$APP_INSTALLED_PATH" ]; then + nddev::log "error" "application destination became foreign before commit; preserving recovery state and bootstrap locks" + return 1 + fi + fi + + # Apply the same reconciliation and identity contract to the launcher. + if [ -n "$LAUNCHER_STAGE_PATH" ]; then + if nddev::identity_matches "$LAUNCHER_STAGE_PATH" regular "$LAUNCHER_STAGE_IDENTITY"; then + : + elif nddev::identity_matches "$LAUNCHER_PATH" regular "$LAUNCHER_STAGE_IDENTITY" \ + && { [ ! -e "$LAUNCHER_STAGE_PATH" ] && [ ! -L "$LAUNCHER_STAGE_PATH" ]; }; then + LAUNCHER_LIVE_IDENTITY="$LAUNCHER_STAGE_IDENTITY" + LAUNCHER_STAGE_PATH="" + else + nddev::log "error" "launcher stage identity changed; preserving recovery state and bootstrap locks" + return 1 + fi + fi -# ─── Check if already installed ────────────────────────────────────────── -nddev::check_runtime_version -if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - running_app="skipped-plan" + if [ -n "$LAUNCHER_OLD_PATH" ]; then + if nddev::identity_matches "$LAUNCHER_OLD_PATH" regular "$LAUNCHER_OLD_IDENTITY"; then + : + elif [ "$success" -eq 1 ] && [ -n "$LAUNCHER_LIVE_IDENTITY" ] \ + && { [ ! -e "$LAUNCHER_OLD_PATH" ] && [ ! -L "$LAUNCHER_OLD_PATH" ]; }; then + LAUNCHER_OLD_PATH="" + LAUNCHER_OLD_IDENTITY="" + elif [ -z "$LAUNCHER_LIVE_IDENTITY" ] && [ -n "$LAUNCHER_STAGE_PATH" ] \ + && nddev::identity_matches "$LAUNCHER_PATH" regular "$LAUNCHER_OLD_IDENTITY" \ + && { [ ! -e "$LAUNCHER_OLD_PATH" ] && [ ! -L "$LAUNCHER_OLD_PATH" ]; }; then + LAUNCHER_OLD_PATH="" + LAUNCHER_OLD_IDENTITY="" + LAUNCHER_SWAPPED=0 + else + nddev::log "error" "old launcher identity changed; preserving recovery state and bootstrap locks" + return 1 + fi + fi + + if [ -n "$LAUNCHER_LIVE_IDENTITY" ]; then + if ! nddev::identity_matches "$LAUNCHER_PATH" regular "$LAUNCHER_LIVE_IDENTITY"; then + nddev::log "error" "installed launcher identity changed; preserving recovery state and bootstrap locks" + return 1 + fi + elif [ "$LAUNCHER_SWAPPED" -eq 1 ]; then + if [ -z "$LAUNCHER_STAGE_PATH" ]; then + nddev::log "error" "launcher swap state is ambiguous; preserving recovery state and bootstrap locks" + return 1 + fi + if [ -e "$LAUNCHER_PATH" ] || [ -L "$LAUNCHER_PATH" ]; then + nddev::log "error" "launcher destination became foreign before commit; preserving recovery state and bootstrap locks" + return 1 + fi + fi +} + +nddev::cleanup_bootstrap_swaps() { + local success=$1 failed_path="" errors=0 + nddev::bootstrap_identity_preflight "$success" || return 1 + if [ "$LAUNCHER_SWAPPED" -eq 1 ]; then + if [ "$success" -ne 1 ] && [ -n "$LAUNCHER_STAGE_PATH" ] \ + && [ -f "$LAUNCHER_STAGE_PATH" ] && { [ -e "$LAUNCHER_PATH" ] || [ -L "$LAUNCHER_PATH" ]; }; then + nddev::log "error" "launcher destination became occupied before commit; preserving it and rollback state for inspection" + errors=1 + elif [ "$success" -eq 1 ]; then + if [ -n "$LAUNCHER_OLD_PATH" ]; then + if [ -f "$LAUNCHER_OLD_PATH" ] && [ ! -L "$LAUNCHER_OLD_PATH" ]; then + if nddev::remove_regular_file "$LAUNCHER_OLD_PATH" "$LAUNCHER_OLD_IDENTITY"; then + LAUNCHER_OLD_PATH="" + LAUNCHER_OLD_IDENTITY="" + else + nddev::log "error" "committed launcher is safe, but old launcher cleanup failed: $LAUNCHER_OLD_PATH" + errors=1 + fi + elif [ -e "$LAUNCHER_OLD_PATH" ] || [ -L "$LAUNCHER_OLD_PATH" ]; then + nddev::log "error" "old launcher endpoint changed type; inspect manually: $LAUNCHER_OLD_PATH" + errors=1 + else + LAUNCHER_OLD_PATH="" + LAUNCHER_OLD_IDENTITY="" + fi + fi + [ -n "$LAUNCHER_OLD_PATH" ] || LAUNCHER_SWAPPED=0 + else + if [ -n "$LAUNCHER_OLD_PATH" ] && [ -f "$LAUNCHER_OLD_PATH" ] \ + && [ ! -L "$LAUNCHER_OLD_PATH" ]; then + failed_path="$(dirname "$LAUNCHER_PATH")/.zcode.launcher.failed.$$" + if [ -e "$failed_path" ] || [ -L "$failed_path" ]; then + nddev::log "error" "launcher rollback path collision: $failed_path" + errors=1 + elif { [ ! -e "$LAUNCHER_PATH" ] && [ ! -L "$LAUNCHER_PATH" ]; } \ + || { [ -f "$LAUNCHER_PATH" ] && [ ! -L "$LAUNCHER_PATH" ] \ + && nddev::rename_noreplace \ + "$LAUNCHER_PATH" "$failed_path" regular "$LAUNCHER_LIVE_IDENTITY"; }; then + if nddev::rename_noreplace \ + "$LAUNCHER_OLD_PATH" "$LAUNCHER_PATH" regular "$LAUNCHER_OLD_IDENTITY"; then + LAUNCHER_OLD_PATH="" + LAUNCHER_OLD_IDENTITY="" + LAUNCHER_SWAPPED=0 + if [ -f "$failed_path" ] \ + && ! nddev::remove_regular_file "$failed_path" "$LAUNCHER_LIVE_IDENTITY"; then + nddev::log "error" "launcher rollback succeeded, but failed launcher cleanup did not: $failed_path" + errors=1 + fi + else + nddev::log "error" "failed to restore old launcher from $LAUNCHER_OLD_PATH" + errors=1 + if [ -f "$failed_path" ] && [ ! -e "$LAUNCHER_PATH" ]; then + nddev::rename_noreplace \ + "$failed_path" "$LAUNCHER_PATH" regular "$LAUNCHER_LIVE_IDENTITY" \ + || nddev::log "error" "failed launcher remains recoverable at $failed_path" + fi + fi + else + nddev::log "error" "new launcher endpoint is unsafe or could not be moved for rollback: $LAUNCHER_PATH" + errors=1 + fi + elif [ -n "$LAUNCHER_OLD_PATH" ]; then + nddev::log "error" "old launcher rollback source is missing or unsafe: $LAUNCHER_OLD_PATH" + errors=1 + elif [ -f "$LAUNCHER_PATH" ] && [ ! -L "$LAUNCHER_PATH" ]; then + if nddev::remove_regular_file "$LAUNCHER_PATH" "$LAUNCHER_LIVE_IDENTITY"; then + LAUNCHER_SWAPPED=0 + else + nddev::log "error" "failed to remove new launcher during rollback: $LAUNCHER_PATH" + errors=1 + fi + elif [ -e "$LAUNCHER_PATH" ] || [ -L "$LAUNCHER_PATH" ]; then + nddev::log "error" "new launcher endpoint changed type during rollback: $LAUNCHER_PATH" + errors=1 + else + LAUNCHER_SWAPPED=0 + fi + fi + fi + if [ "$APP_SWAPPED" -eq 1 ]; then + if [ "$success" -ne 1 ] && [ -n "$APP_STAGE_PATH" ] \ + && [ -d "$APP_STAGE_PATH" ] && { [ -e "$APP_INSTALLED_PATH" ] || [ -L "$APP_INSTALLED_PATH" ]; }; then + nddev::log "error" "application destination became occupied before commit; preserving it and rollback state for inspection" + errors=1 + elif [ "$success" -eq 1 ]; then + if [ -n "$APP_OLD_PATH" ] && [ -d "$APP_OLD_PATH" ] && [ ! -L "$APP_OLD_PATH" ]; then + if nddev::remove_direct_child_tree \ + "$(dirname "$APP_OLD_PATH")" "$APP_OLD_PATH" "$APP_OLD_IDENTITY"; then + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + else + nddev::log "error" "committed application is safe, but old application cleanup failed: $APP_OLD_PATH" + errors=1 + fi + elif [ -n "$APP_OLD_PATH" ] && { [ -e "$APP_OLD_PATH" ] || [ -L "$APP_OLD_PATH" ]; }; then + nddev::log "error" "old application endpoint changed type; inspect manually: $APP_OLD_PATH" + errors=1 + else + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + fi + [ -n "$APP_OLD_PATH" ] || APP_SWAPPED=0 + elif [ -n "$APP_OLD_PATH" ] && [ -d "$APP_OLD_PATH" ] && [ ! -L "$APP_OLD_PATH" ]; then + if [ ! -e "$APP_INSTALLED_PATH" ]; then + if nddev::rename_noreplace \ + "$APP_OLD_PATH" "$APP_INSTALLED_PATH" directory "$APP_OLD_IDENTITY"; then + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + APP_SWAPPED=0 + else + nddev::log "error" "failed to restore old application from $APP_OLD_PATH" + errors=1 + fi + elif [ -L "$APP_INSTALLED_PATH" ] || [ ! -d "$APP_INSTALLED_PATH" ]; then + nddev::log "error" "installed application endpoint changed type during rollback: $APP_INSTALLED_PATH" + errors=1 + else + failed_path="$(dirname "$APP_INSTALLED_PATH")/.nddev-failed-app.$$" + if [ -e "$failed_path" ] || [ -L "$failed_path" ]; then + nddev::log "error" "application rollback path collision: $failed_path" + errors=1 + elif nddev::rename_noreplace \ + "$APP_INSTALLED_PATH" "$failed_path" directory "$APP_LIVE_IDENTITY"; then + if nddev::rename_noreplace \ + "$APP_OLD_PATH" "$APP_INSTALLED_PATH" directory "$APP_OLD_IDENTITY"; then + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + APP_SWAPPED=0 + if ! nddev::remove_direct_child_tree \ + "$(dirname "$failed_path")" "$failed_path" "$APP_LIVE_IDENTITY"; then + nddev::log "error" "application rollback succeeded, but failed application cleanup did not: $failed_path" + errors=1 + fi + else + nddev::log "error" "failed to restore old application from $APP_OLD_PATH" + errors=1 + if [ ! -e "$APP_INSTALLED_PATH" ]; then + nddev::rename_noreplace \ + "$failed_path" "$APP_INSTALLED_PATH" directory "$APP_LIVE_IDENTITY" \ + || nddev::log "error" "failed application remains recoverable at $failed_path" + fi + fi + else + nddev::log "error" "failed to move new application aside for rollback: $APP_INSTALLED_PATH" + errors=1 + fi + fi + elif [ -n "$APP_OLD_PATH" ]; then + nddev::log "error" "old application rollback source is missing or unsafe: $APP_OLD_PATH" + errors=1 + elif [ -d "$APP_INSTALLED_PATH" ] && [ ! -L "$APP_INSTALLED_PATH" ]; then + if nddev::remove_direct_child_tree \ + "$(dirname "$APP_INSTALLED_PATH")" "$APP_INSTALLED_PATH" "$APP_LIVE_IDENTITY"; then + APP_SWAPPED=0 + else + nddev::log "error" "failed to remove new application during rollback: $APP_INSTALLED_PATH" + errors=1 + fi + elif [ -e "$APP_INSTALLED_PATH" ] || [ -L "$APP_INSTALLED_PATH" ]; then + nddev::log "error" "new application endpoint changed type during rollback: $APP_INSTALLED_PATH" + errors=1 + else + APP_SWAPPED=0 + fi + fi + if [ -n "$APP_STAGE_PATH" ]; then + if [ -d "$APP_STAGE_PATH" ] && [ ! -L "$APP_STAGE_PATH" ]; then + if nddev::remove_direct_child_tree \ + "$(dirname "$APP_STAGE_PATH")" "$APP_STAGE_PATH" "$APP_STAGE_IDENTITY"; then + APP_STAGE_PATH="" + APP_STAGE_IDENTITY="" + else + nddev::log "error" "failed to remove application staging tree: $APP_STAGE_PATH" + errors=1 + fi + elif [ -e "$APP_STAGE_PATH" ] || [ -L "$APP_STAGE_PATH" ]; then + nddev::log "error" "application staging endpoint changed type: $APP_STAGE_PATH" + errors=1 + else + APP_STAGE_PATH="" + APP_STAGE_IDENTITY="" + fi + fi + if [ -n "$LAUNCHER_STAGE_PATH" ]; then + if [ -f "$LAUNCHER_STAGE_PATH" ] && [ ! -L "$LAUNCHER_STAGE_PATH" ]; then + if nddev::remove_regular_file "$LAUNCHER_STAGE_PATH" "$LAUNCHER_STAGE_IDENTITY"; then + LAUNCHER_STAGE_PATH="" + LAUNCHER_STAGE_IDENTITY="" + else + nddev::log "error" "failed to remove launcher staging file: $LAUNCHER_STAGE_PATH" + errors=1 + fi + elif [ -e "$LAUNCHER_STAGE_PATH" ] || [ -L "$LAUNCHER_STAGE_PATH" ]; then + nddev::log "error" "launcher staging endpoint changed type: $LAUNCHER_STAGE_PATH" + errors=1 + else + LAUNCHER_STAGE_PATH="" + LAUNCHER_STAGE_IDENTITY="" + fi + fi + return "$errors" +} + +nddev::cleanup_bootstrap_resources() { + local success=$1 errors=0 swaps_clean=1 + if { [ "$MOUNTED" -eq 1 ] || [ "$MOUNT_ATTEMPTED" -eq 1 ]; } && [ -n "$MOUNT_POINT" ]; then + nddev::detach_image || errors=1 + fi + nddev::cleanup_bootstrap_swaps "$success" || { + errors=1 + swaps_clean=0 + } + if [ -n "$TMP_ROOT" ]; then + if [ "$MOUNTED" -eq 1 ] || [ "$MOUNT_ATTEMPTED" -eq 1 ]; then + nddev::log "error" "temporary bootstrap root is preserved because its image is still mounted: $TMP_ROOT" + errors=1 + elif [ -d "$TMP_ROOT" ] && [ ! -L "$TMP_ROOT" ]; then + if nddev::remove_direct_child_tree "$(dirname "$TMP_ROOT")" "$TMP_ROOT"; then + TMP_ROOT="" + else + nddev::log "error" "failed to remove temporary bootstrap root: $TMP_ROOT" + errors=1 + fi + elif [ -e "$TMP_ROOT" ] || [ -L "$TMP_ROOT" ]; then + nddev::log "error" "temporary bootstrap root changed type; inspect manually: $TMP_ROOT" + errors=1 + else + TMP_ROOT="" + fi + fi + if [ "$swaps_clean" -eq 1 ]; then + nddev::release_bootstrap_locks || errors=1 + elif [ "${#BOOTSTRAP_LOCK_PATHS[@]}" -gt 0 ]; then + nddev::log "error" "bootstrap locks are preserved because swap recovery is incomplete" + fi + return "$errors" +} + +if [ "$APPLY" -eq 1 ]; then + TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/nddev-zcode-bootstrap.XXXXXX")" + trap 'nddev::bootstrap_cleanup' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + chmod 700 "$TMP_ROOT" + downloaded="$TMP_ROOT/$artifact" else - running_app="$(nddev::detect_app_version)" + downloaded="${TMPDIR:-/tmp}/$artifact" fi -if [ "$running_app" = "$APP_VERSION" ]; then - nddev::log "ok" "ZCode $APP_VERSION already installed; skipping app download" + +nddev::sha512_file() { + python3 -I - "$1" <<'PY' +import hashlib +import sys + +digest = hashlib.sha512() +with open(sys.argv[1], "rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) +print(digest.hexdigest()) +PY +} + +nddev::verify_download() { + local path=$1 actual_size actual_sha + actual_size="$(wc -c < "$path" | tr -d '[:space:]')" + if [ "$actual_size" != "$expected_size" ]; then + nddev::log "error" "artifact size mismatch: expected $expected_size, got $actual_size" + return 1 + fi + actual_sha="$(nddev::sha512_file "$path")" + if [ "$actual_sha" != "$expected_sha512" ]; then + nddev::log "error" "artifact SHA-512 mismatch: expected $expected_sha512, got $actual_sha" + return 1 + fi + nddev::log "ok" "artifact size and SHA-512 verified" +} + +nddev::section "Download and verify artifact" +nddev::log "info" "url: $url" +if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] curl --disable --fail --location --proto =https --proto-redir =https --tlsv1.2 --max-filesize %s --output %q %q\n' "$expected_size" "$downloaded" "$url" + printf '[DRY-RUN] verify size=%s and sha512=%s\n' "$expected_size" "$expected_sha512" else - nddev::log "info" "ZCode $APP_VERSION not found (running: $running_app); will install" + curl --disable --fail --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 3 --retry-all-errors --connect-timeout 20 --max-filesize "$expected_size" \ + --output "$downloaded" "$url" + chmod 600 "$downloaded" + nddev::verify_download "$downloaded" fi -# ─── Download + install per platform ───────────────────────────────────── -if [ "$running_app" != "$APP_VERSION" ]; then - case "$PLATFORM" in - macos) - artifact="ZCode-${APP_VERSION}-mac-${arch}.dmg" - url="${CDN_BASE}/${APP_VERSION}/${artifact}" - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - tmp_dmg="${TMPDIR:-/tmp}/nddev-zcode-${APP_VERSION}-${arch}.dmg" - else - tmp_dmg="$(mktemp -t nddev-zcode.XXXXXX)" - fi +nddev::macos_identity() { + local app=$1 actual_version actual_build actual_bundle actual_team details assessment + [ -d "$app" ] && [ ! -L "$app" ] || { nddev::log "error" "invalid ZCode.app endpoint: $app"; return 1; } + codesign --verify --deep --strict --verbose=2 "$app" >/dev/null 2>&1 || { + nddev::log "error" "ZCode.app code signature verification failed" + return 1 + } + assessment="$(LC_ALL=C spctl --assess --type execute --verbose=4 "$app" 2>&1)" || { + nddev::log "error" "ZCode.app Gatekeeper assessment failed" + return 1 + } + if ! printf '%s\n' "$assessment" | grep -q '^source=Notarized Developer ID$'; then + nddev::log "error" "ZCode.app is not accepted as a notarized Developer ID application" + return 1 + fi + actual_version="$(/usr/bin/defaults read "$app/Contents/Info" CFBundleShortVersionString 2>/dev/null || true)" + actual_build="$(/usr/bin/defaults read "$app/Contents/Info" CFBundleVersion 2>/dev/null || true)" + actual_bundle="$(/usr/bin/defaults read "$app/Contents/Info" CFBundleIdentifier 2>/dev/null || true)" + details="$(LC_ALL=C codesign -dv --verbose=4 "$app" 2>&1)" + actual_team="$(printf '%s\n' "$details" | sed -n 's/^TeamIdentifier=//p' | head -1)" + [ "$actual_version" = "$APP_VERSION" ] || { nddev::log "error" "macOS app version mismatch: $actual_version"; return 1; } + [ "$actual_build" = "$BUNDLE_VERSION" ] || { nddev::log "error" "macOS bundle version mismatch: $actual_build"; return 1; } + [ "$actual_bundle" = "$BUNDLE_ID" ] || { nddev::log "error" "macOS bundle identifier mismatch: $actual_bundle"; return 1; } + [ "$actual_team" = "$TEAM_ID" ] || { nddev::log "error" "macOS Team ID mismatch: $actual_team"; return 1; } +} + +nddev::deb_identity() { + local path=$1 actual_name actual_version actual_arch + actual_name="$(dpkg-deb -f "$path" Package 2>/dev/null || true)" + actual_version="$(dpkg-deb -f "$path" Version 2>/dev/null || true)" + actual_arch="$(dpkg-deb -f "$path" Architecture 2>/dev/null || true)" + [ "$actual_name" = "$PACKAGE_NAME" ] || { nddev::log "error" "DEB package mismatch: $actual_name"; return 1; } + [ "$actual_version" = "$PACKAGE_VERSION" ] || { nddev::log "error" "DEB version mismatch: $actual_version"; return 1; } + [ "$actual_arch" = "$PACKAGE_ARCH" ] || { nddev::log "error" "DEB architecture mismatch: $actual_arch"; return 1; } + nddev::log "ok" "DEB control metadata verified ($actual_name $actual_version $actual_arch)" +} + +nddev::resolve_deb_cli() { + local package=$1 expected=$2 + dpkg-query -L "$package" 2>/dev/null | python3 -I -c ' +import os +import sys - nddev::section "Download ZCode.app ($artifact)" - nddev::log "info" "url: $url" - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] curl -fL -o %q %q\n' "$tmp_dmg" "$url" +expected = sys.argv[1] +if not os.path.isabs(expected) or os.path.normpath(expected) != expected: + raise SystemExit("unsafe expected package CLI path") +matches = [] +unexpected = [] +for raw in sys.stdin: + path = raw.rstrip("\n") + if not path.endswith("/resources/glm/zcode.cjs"): + continue + if not os.path.isabs(path) or os.path.normpath(path) != path: + raise SystemExit("unsafe package CLI path") + if path == expected: + matches.append(path) + else: + unexpected.append(path) +if unexpected: + raise SystemExit("package CLI path does not match the verified layout") +if len(matches) != 1: + raise SystemExit(f"expected one exact package-owned CLI entry, found {len(matches)}") +print(matches[0]) +' "$expected" +} + +bin_raw="$(nddev::canonical_path "$HOME")/.local/bin" +bin_dir="$(nddev::bootstrap_destination "CLI launcher directory" "$bin_raw")" || exit 2 +launcher="$bin_dir/zcode" +LAUNCHER_PATH="$launcher" +if [ "$APPLY" -eq 1 ]; then + nddev::ensure_dir "$bin_dir" +fi + +app_entry="" +case "$INSTALL_KIND" in + dmg) + applications_raw="${NDDEV_APPLICATIONS_DIR-/Applications}" + applications="$(nddev::bootstrap_destination "applications directory" "$applications_raw")" || exit 2 + if [ "$APPLY" -eq 1 ] && [ ! -d "$applications" ]; then + nddev::log "error" "applications directory must already exist in apply mode: $applications" + exit 2 + fi + nddev::acquire_bootstrap_locks \ + "$applications/.ZCode.app.nddev-bootstrap-lock" \ + "$bin_dir/.zcode.nddev-bootstrap-lock" || exit 1 + if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] hdiutil verify %q\n' "$downloaded" + printf '[DRY-RUN] mount, verify Team ID %s / bundle %s / versions %s (%s), then atomically install %q\n' \ + "$TEAM_ID" "$BUNDLE_ID" "$APP_VERSION" "$BUNDLE_VERSION" "$applications/ZCode.app" + app_entry="$applications/ZCode.app/Contents/Resources/glm/zcode.cjs" + else + nddev::require_cmd hdiutil required + nddev::require_cmd codesign required + nddev::require_cmd spctl required + nddev::require_cmd ditto required + hdiutil verify "$downloaded" >/dev/null + MOUNT_POINT="$TMP_ROOT/mount" + mkdir -m 700 "$MOUNT_POINT" + if command -v diskutil >/dev/null 2>&1 && diskutil image attach --help >/dev/null 2>&1; then + MOUNT_TOOL="diskutil" + MOUNT_ATTEMPTED=1 + diskutil image attach --readOnly --nobrowse --mountPoint "$MOUNT_POINT" "$downloaded" >/dev/null else - curl -fL --progress-bar -o "$tmp_dmg" "$url" - nddev::log "ok" "downloaded $(du -h "$tmp_dmg" | cut -f1)" + MOUNT_TOOL="hdiutil" + MOUNT_ATTEMPTED=1 + hdiutil attach "$downloaded" -nobrowse -readonly -mountpoint "$MOUNT_POINT" >/dev/null fi + source_app="$MOUNT_POINT/ZCode.app" + if [ ! -d "$source_app" ] || [ -L "$source_app" ]; then + nddev::log "error" "disk image attach returned without the expected application endpoint" + exit 1 + fi + MOUNTED=1 + nddev::macos_identity "$source_app" - nddev::section "Install ZCode.app" - mount_point="/Volumes/ZCode-${APP_VERSION}" - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] hdiutil attach %q\n' "$tmp_dmg" - printf '[DRY-RUN] cp -R "/Volumes/ZCode/ZCode.app" /Applications/\n' - printf '[DRY-RUN] hdiutil detach %q\n' "$mount_point" - else - hdiutil attach "$tmp_dmg" -nobrowse -mountpoint "$mount_point" >/dev/null 2>&1 - cp -R "${mount_point}/ZCode.app" /Applications/ - hdiutil detach "$mount_point" >/dev/null 2>&1 - nddev::log "ok" "installed ZCode.app to /Applications/" + installed_app="$applications/ZCode.app" + stage_app="$applications/.ZCode.app.nddev-stage.$$" + old_app="$applications/.ZCode.app.nddev-old.$$" + if [ -e "$stage_app" ] || [ -L "$stage_app" ] \ + || [ -e "$old_app" ] || [ -L "$old_app" ]; then + nddev::log "error" "macOS install staging collision" + exit 1 fi - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - rm -f "$tmp_dmg" + if [ -L "$installed_app" ] || { [ -e "$installed_app" ] && [ ! -d "$installed_app" ]; }; then + nddev::log "error" "existing app endpoint is not a real directory: $installed_app" + exit 1 fi - app_entry="/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs" - ;; + APP_STAGE_PATH="$stage_app" + ditto "$source_app" "$stage_app" + nddev::macos_identity "$stage_app" + APP_STAGE_IDENTITY="$(nddev::path_identity "$stage_app" directory)" || exit 1 + APP_INSTALLED_PATH="$installed_app" + if [ -d "$installed_app" ]; then + APP_OLD_PATH="$old_app" + APP_OLD_IDENTITY="$(nddev::path_identity "$installed_app" directory)" || exit 1 + APP_SWAPPED=1 + if ! nddev::rename_noreplace \ + "$installed_app" "$old_app" directory "$APP_OLD_IDENTITY"; then + if [ -d "$installed_app" ] && [ ! -e "$old_app" ]; then + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + APP_SWAPPED=0 + fi + exit 1 + fi + fi + APP_SWAPPED=1 + if ! nddev::rename_noreplace \ + "$stage_app" "$installed_app" directory "$APP_STAGE_IDENTITY"; then + exit 1 + fi + APP_LIVE_IDENTITY="$APP_STAGE_IDENTITY" + APP_STAGE_PATH="" + APP_SWAPPED=1 + nddev::macos_identity "$installed_app" + app_entry="$installed_app/Contents/Resources/glm/zcode.cjs" + fi + ;; - ubuntu) - # Prefer .deb (cleaner uninstall); fall back to AppImage. - artifact="ZCode-${APP_VERSION}-linux-${arch}.deb" - url="${CDN_BASE}/${APP_VERSION}/${artifact}" - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - tmp_deb="${TMPDIR:-/tmp}/nddev-zcode-${APP_VERSION}-${arch}.deb" + deb) + app_entry="/resources/glm/zcode.cjs" + nddev::acquire_bootstrap_locks "$bin_dir/.zcode.nddev-bootstrap-lock" || exit 1 + if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] verify DEB Package=%s Version=%s Architecture=%s CLI=%s\n' "$PACKAGE_NAME" "$PACKAGE_VERSION" "$PACKAGE_ARCH" "$DEB_CLI_ENTRY" + printf '[DRY-RUN] require successful dpkg --dry-run -i, then install with dpkg -i; errors are fatal (no AppImage fallback)\n' + else + nddev::deb_identity "$downloaded" + deb_extract_root="$TMP_ROOT/deb-extract" + mkdir -m 700 "$deb_extract_root" + dpkg-deb -x "$downloaded" "$deb_extract_root" + preinstall_cli="$deb_extract_root/${DEB_CLI_ENTRY#/}" + python3 -I - "$deb_extract_root" "$DEB_CLI_ENTRY" <<'PY' +import os +import stat +import sys + +root, expected = sys.argv[1:] +expected_relative = expected.lstrip("/") +matches = [] +for directory, names, files in os.walk(root, topdown=True, followlinks=False): + for name in names + files: + path = os.path.join(directory, name) + relative = os.path.relpath(path, root) + if relative.endswith("resources/glm/zcode.cjs"): + matches.append(relative) +if matches != [expected_relative]: + raise SystemExit(f"DEB payload CLI layout mismatch: {matches}") +candidate = os.path.join(root, expected_relative) +metadata = os.lstat(candidate) +if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1: + raise SystemExit("DEB payload CLI must be one uniquely linked regular file") +PY + preinstall_cli_version="$(nddev::probe_cli_version node "$preinstall_cli")" + [ "$preinstall_cli_version" = "$CLI_VERSION" ] \ + || { nddev::log "error" "DEB payload CLI mismatch before install: $preinstall_cli_version"; exit 1; } + preinstall_cli_sha="$(nddev::sha512_file "$preinstall_cli")" + nddev::log "ok" "DEB payload exact CLI layout and version verified before install" + if [ "$(id -u)" -eq 0 ]; then + dpkg --dry-run -i "$downloaded" + dpkg -i "$downloaded" else - tmp_deb="$(mktemp -t nddev-zcode.XXXXXX)" + nddev::require_cmd sudo required + sudo -- dpkg --dry-run -i "$downloaded" + sudo -- dpkg -i "$downloaded" fi + installed_version="$(dpkg-query -W -f='${Version}' "$PACKAGE_NAME" 2>/dev/null || true)" + installed_arch="$(dpkg-query -W -f='${Architecture}' "$PACKAGE_NAME" 2>/dev/null || true)" + [ "$installed_version" = "$PACKAGE_VERSION" ] || { nddev::log "error" "installed DEB version mismatch: $installed_version"; exit 1; } + [ "$installed_arch" = "$PACKAGE_ARCH" ] || { nddev::log "error" "installed DEB architecture mismatch: $installed_arch"; exit 1; } + package_cli="$(nddev::resolve_deb_cli "$PACKAGE_NAME" "$DEB_CLI_ENTRY")" || { nddev::log "error" "installed DEB has no exact package-owned CLI entry"; exit 1; } + [ -f "$package_cli" ] && [ ! -L "$package_cli" ] || { nddev::log "error" "package-owned CLI entry is missing or unsafe: $package_cli"; exit 1; } + package_cli_version="$(nddev::probe_cli_version node "$package_cli")" + [ "$package_cli_version" = "$CLI_VERSION" ] || { nddev::log "error" "installed DEB CLI mismatch: $package_cli_version"; exit 1; } + installed_cli_sha="$(nddev::sha512_file "$package_cli")" + [ "$installed_cli_sha" = "$preinstall_cli_sha" ] \ + || { nddev::log "error" "installed DEB CLI differs from the verified payload"; exit 1; } + app_entry="$package_cli" + fi + ;; - nddev::section "Download ZCode ($artifact)" - nddev::log "info" "url: $url" - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] curl -fL -o %q %q\n' "$tmp_deb" "$url" - printf '[DRY-RUN] sudo dpkg -i %q\n' "$tmp_deb" - else - curl -fL --progress-bar -o "$tmp_deb" "$url" - nddev::log "ok" "downloaded $(du -h "$tmp_deb" | cut -f1)" - if command -v sudo >/dev/null 2>&1; then - sudo dpkg -i "$tmp_deb" || sudo apt-get install -f -y - else - dpkg -i "$tmp_deb" || apt-get install -f -y + appimage) + if [ -n "${NDDEV_APPIMAGE_INSTALL_DIR+x}" ]; then + appimage_raw="$NDDEV_APPIMAGE_INSTALL_DIR" + else + appimage_raw="$(nddev::canonical_path "$HOME")/.local/opt/ZCode" + fi + appimage_root="$(nddev::bootstrap_destination "AppImage install directory" "$appimage_raw")" || exit 2 + app_parent="$(dirname "$appimage_root")" + if [ "$APPLY" -eq 1 ]; then + nddev::ensure_dir "$app_parent" + fi + nddev::acquire_bootstrap_locks \ + "$app_parent/.ZCode.nddev-bootstrap-lock" \ + "$bin_dir/.zcode.nddev-bootstrap-lock" || exit 1 + app_entry="$appimage_root/resources/glm/zcode.cjs" + if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] extract verified AppImage into same-filesystem stage and atomically install %q\n' "$appimage_root" + else + chmod 700 "$downloaded" + extract_root="$TMP_ROOT/appimage-extract" + mkdir -m 700 "$extract_root" + (cd "$extract_root" && "$downloaded" --appimage-extract >/dev/null) + extracted="$extract_root/squashfs-root" + extracted_entry="$extracted/resources/glm/zcode.cjs" + [ -f "$extracted_entry" ] || { nddev::log "error" "AppImage does not contain the expected CLI entry"; exit 1; } + embedded_cli="$(nddev::probe_cli_version node "$extracted_entry")" + [ "$embedded_cli" = "$CLI_VERSION" ] || { nddev::log "error" "AppImage embedded CLI mismatch: $embedded_cli"; exit 1; } + + if [ -L "$appimage_root" ] || { [ -e "$appimage_root" ] && [ ! -d "$appimage_root" ]; }; then + nddev::log "error" "existing AppImage install root is not a real directory" + exit 1 + fi + app_stage="$(mktemp -d "$app_parent/.ZCode.stage.XXXXXX")" + APP_STAGE_PATH="$app_stage" + chmod 700 "$app_stage" + cp -R "$extracted/." "$app_stage/" + cp "$downloaded" "$app_stage/ZCode.AppImage" + chmod 700 "$app_stage/ZCode.AppImage" + printf '%s\n' "$APP_VERSION" > "$app_stage/.nddev-app-version" + chmod 600 "$app_stage/.nddev-app-version" + APP_STAGE_IDENTITY="$(nddev::path_identity "$app_stage" directory)" || exit 1 + app_old="$app_parent/.ZCode.old.$$" + [ ! -e "$app_old" ] && [ ! -L "$app_old" ] || { nddev::log "error" "AppImage rollback path collision"; exit 1; } + APP_INSTALLED_PATH="$appimage_root" + if [ -d "$appimage_root" ]; then + APP_OLD_PATH="$app_old" + APP_OLD_IDENTITY="$(nddev::path_identity "$appimage_root" directory)" || exit 1 + APP_SWAPPED=1 + if ! nddev::rename_noreplace \ + "$appimage_root" "$app_old" directory "$APP_OLD_IDENTITY"; then + if [ -d "$appimage_root" ] && [ ! -e "$app_old" ]; then + APP_OLD_PATH="" + APP_OLD_IDENTITY="" + APP_SWAPPED=0 + fi + exit 1 fi - nddev::log "ok" "installed ZCode via dpkg" fi - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - rm -f "$tmp_deb" + APP_SWAPPED=1 + if ! nddev::rename_noreplace \ + "$app_stage" "$appimage_root" directory "$APP_STAGE_IDENTITY"; then + exit 1 fi - app_entry="/opt/ZCode/resources/glm/zcode.cjs" - ;; - esac -fi + APP_LIVE_IDENTITY="$APP_STAGE_IDENTITY" + APP_STAGE_PATH="" + APP_SWAPPED=1 + app_entry="$appimage_root/resources/glm/zcode.cjs" + fi + ;; +esac -# ─── Wire the CLI launcher ─────────────────────────────────────────────── -nddev::section "Wire zcode CLI launcher" -bin_dir="${HOME}/.local/bin" -launcher="${bin_dir}/zcode" - -# M6: Resolve the entry point based on platform (not by guessing macOS first). -# app_entry may already be set if the app was just installed; otherwise pick -# the platform-default path and verify it exists. -if [ -z "${app_entry:-}" ]; then - case "$PLATFORM" in - macos) app_entry="/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs" ;; - ubuntu) app_entry="/opt/ZCode/resources/glm/zcode.cjs" ;; - *) app_entry="/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs" ;; - esac -fi -# If the platform-default doesn't exist, try the other platform's path. -if [ ! -f "$app_entry" ]; then - case "$PLATFORM" in - macos) app_entry="/opt/ZCode/resources/glm/zcode.cjs" ;; - *) app_entry="/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs" ;; - esac +nddev::section "Wire exact CLI launcher" +if [ "$APPLY" -eq 0 ]; then + printf '[DRY-RUN] atomically write launcher %q -> %q\n' "$launcher" "$app_entry" +else + [ -f "$app_entry" ] && [ ! -L "$app_entry" ] || { nddev::log "error" "installed CLI entry missing or unsafe: $app_entry"; exit 1; } + if [ -L "$launcher" ] || { [ -e "$launcher" ] && [ ! -f "$launcher" ]; }; then + nddev::log "error" "refusing to replace unsafe launcher endpoint: $launcher" + exit 1 + fi + launcher_tmp="$(mktemp "$bin_dir/.zcode.launcher.XXXXXX")" + LAUNCHER_STAGE_PATH="$launcher_tmp" + app_entry_quoted="$(printf '%q' "$app_entry")" + # shellcheck disable=SC2016 # launcher variables expand when the generated script runs. + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -euo pipefail' \ + "ZCODE_CJS=$app_entry_quoted" \ + '[ -f "$ZCODE_CJS" ] || { echo "zcode: managed entry is missing: $ZCODE_CJS" >&2; exit 127; }' \ + 'exec node "$ZCODE_CJS" "$@"' > "$launcher_tmp" + chmod 755 "$launcher_tmp" + LAUNCHER_STAGE_IDENTITY="$(nddev::path_identity "$launcher_tmp" regular)" || exit 1 + launcher_old="$bin_dir/.zcode.launcher.old.$$" + [ ! -e "$launcher_old" ] && [ ! -L "$launcher_old" ] || { nddev::log "error" "launcher rollback path collision"; exit 1; } + if [ -f "$launcher" ]; then + LAUNCHER_OLD_PATH="$launcher_old" + LAUNCHER_OLD_IDENTITY="$(nddev::path_identity "$launcher" regular)" || exit 1 + LAUNCHER_SWAPPED=1 + if ! nddev::rename_noreplace \ + "$launcher" "$launcher_old" regular "$LAUNCHER_OLD_IDENTITY"; then + if [ -f "$launcher" ] && [ ! -e "$launcher_old" ]; then + LAUNCHER_OLD_PATH="" + LAUNCHER_OLD_IDENTITY="" + LAUNCHER_SWAPPED=0 + fi + exit 1 + fi + fi + LAUNCHER_SWAPPED=1 + if ! nddev::rename_noreplace \ + "$launcher_tmp" "$launcher" regular "$LAUNCHER_STAGE_IDENTITY"; then + exit 1 + fi + LAUNCHER_LIVE_IDENTITY="$LAUNCHER_STAGE_IDENTITY" + LAUNCHER_STAGE_PATH="" fi -if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] write zcode launcher -> %q (entry: %s)\n' "$launcher" "$app_entry" +nddev::section "Strict postconditions" +if [ "$APPLY" -eq 0 ]; then + nddev::log "info" "plan will require exact app $APP_VERSION, CLI $CLI_VERSION, entrypoint, and platform identity" else - mkdir -p "$bin_dir" - cat > "$launcher" < TUI; 'zcode -p ""' -# runs headless. -ZCODE_CJS="${app_entry}" -[ -f "\$ZCODE_CJS" ] || { echo "zcode: entry not found at \$ZCODE_CJS (is ZCode installed?)" >&2; exit 127; } -exec node "\$ZCODE_CJS" "\$@" -LAUNCHER - chmod +x "$launcher" - nddev::log "ok" "wrote $launcher" - - # Ensure ~/.local/bin is on PATH (best-effort, non-clobbering). - case ":${PATH}:" in - *":${bin_dir}:"*) ;; - *) - nddev::log "warn" "${bin_dir} is not on PATH; add it to your shell rc to use 'zcode'" + if [ -n "$APP_LIVE_IDENTITY" ] \ + && ! nddev::identity_matches "$APP_INSTALLED_PATH" directory "$APP_LIVE_IDENTITY"; then + nddev::log "error" "installed application identity changed before postconditions" + exit 1 + fi + if ! nddev::identity_matches "$LAUNCHER_PATH" regular "$LAUNCHER_LIVE_IDENTITY"; then + nddev::log "error" "installed launcher identity changed before postconditions" + exit 1 + fi + [ -f "$app_entry" ] && [ ! -L "$app_entry" ] || { nddev::log "error" "entrypoint postcondition failed"; exit 1; } + case "$INSTALL_KIND" in + dmg) nddev::macos_identity "$applications/ZCode.app" ;; + deb) + final_deb_version="$(dpkg-query -W -f='${Version}' "$PACKAGE_NAME" 2>/dev/null || true)" + final_deb_arch="$(dpkg-query -W -f='${Architecture}' "$PACKAGE_NAME" 2>/dev/null || true)" + [ "$final_deb_version" = "$PACKAGE_VERSION" ] && [ "$final_deb_arch" = "$PACKAGE_ARCH" ] \ + || { nddev::log "error" "DEB identity postcondition failed"; exit 1; } + ;; + appimage) + [ "$(sed -n '1p' "$appimage_root/.nddev-app-version" 2>/dev/null || true)" = "$APP_VERSION" ] \ + || { nddev::log "error" "AppImage version postcondition failed"; exit 1; } ;; esac + detected_cli="$(nddev::detect_cli_version_at "$launcher")" + [ "$detected_cli" = "$CLI_VERSION" ] || { nddev::log "error" "CLI postcondition failed: $detected_cli"; exit 1; } + nddev::log "ok" "ZCode app $APP_VERSION and CLI $CLI_VERSION verified exactly" fi -# ─── Verify ────────────────────────────────────────────────────────────── -nddev::section "Verify" -nddev::check_runtime_version - -nddev::section "Bootstrap complete" -if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - nddev::log "info" "ZCode app: skipped-plan" - nddev::log "info" "ZCode CLI: skipped-plan" +if [ "$APPLY" -eq 1 ]; then + # Postconditions define the commit point. Cleanup after this point may fail + # visibly, but it must never roll back the verified launcher/application. + BOOTSTRAP_COMMITTED=1 + if ! nddev::cleanup_bootstrap_resources 1; then + trap - EXIT INT TERM HUP + nddev::log "error" "bootstrap committed safely, but cleanup is incomplete; command reports failure" + exit 1 + fi + trap - EXIT INT TERM HUP else - nddev::log "ok" "ZCode app: $(nddev::detect_app_version)" - nddev::log "ok" "ZCode CLI: $(nddev::detect_cli_version)" + nddev::release_bootstrap_locks fi -nddev::log "info" "next: run 'install.sh install --marketplace --apply' to configure ~/.zcode" + +nddev::section "Bootstrap complete" +nddev::log "info" "next: install.sh install --marketplace --apply" diff --git a/cli-tools/scripts/install.sh b/cli-tools/scripts/install.sh index 881674f..490ca0c 100755 --- a/cli-tools/scripts/install.sh +++ b/cli-tools/scripts/install.sh @@ -28,9 +28,6 @@ LIB_DIR="$SCRIPT_DIR/lib" # shellcheck source=lib/version.sh . "$LIB_DIR/version.sh" -# Load build/.env early (target dir + secrets). Env vars already set win. -nddev::load_env - # ─── Defaults ──────────────────────────────────────────────────────────── COMMAND="install" APPLY=0 @@ -38,6 +35,20 @@ PLATFORM="auto" MARKETPLACE="" TARGET_OVERRIDE="" SLOT="" +ADOPT_UNMANAGED=0 +ALLOW_TARGET_RELOCATION=0 +COMMAND_EXPLICIT=0 +SEEN_MARKETPLACE=0 +SEEN_TARGET=0 +SEEN_PLATFORM=0 +SEEN_APPLY=0 +SEEN_PLAN=0 +SEEN_KEEP_BACKUP=0 +SEEN_SLOT=0 +SEEN_ADOPT=0 +SEEN_RELOCATION=0 +SEEN_BACKUPS=0 +SEEN_LIST=0 usage() { cat <<'EOF' @@ -60,15 +71,19 @@ Options (install): --platform macos|ubuntu Target platform (default: auto-detect from uname). --apply Execute (default is --plan / dry-run). --plan | --dry-run Print actions without writing (default). + --adopt-unmanaged Allow install to replace an explicitly selected, + existing unstamped --target after backing it up. Options (remove): --target Directory to remove (default: ~/.zcode, or ZCODE_TARGET in .env). --apply Actually delete (default is --plan). - --keep-backup Move the target here instead of the default backups dir. + --keep-backup Use this backup root for the generated numbered slot. Options (restore): --slot Backup slot to restore (0-9). Required. --target Restore destination (default: ~/.zcode, or ZCODE_TARGET in .env). + --allow-target-relocation Restore an adopted-unmanaged envelope to a different, + explicitly selected --target. --apply Execute the restore (default is --plan). Target resolution (install/remove/restore): @@ -89,10 +104,29 @@ list_marketplaces() { fi local d for d in "$root"/*/; do - [ -d "$d" ] || continue + [ -d "$d" ] && [ ! -L "$d" ] || continue local name desc name="$(basename "$d")" - desc="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('description',''))" "${d}marketplace.json" 2>/dev/null || echo '')" + printf '%s\n' "$name" | grep -qE '^[a-z0-9][a-z0-9-]*$' || continue + if ! desc="$(python3 -I - "${d}marketplace.json" <<'PY' +import json +import os +import stat +import sys + +path = sys.argv[1] +metadata = os.lstat(path) +if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise SystemExit(1) +with open(path, encoding="utf-8") as stream: + description = json.load(stream).get("description", "") +if not isinstance(description, str) or any(ord(char) < 32 or ord(char) == 127 for char in description): + raise SystemExit(1) +print(description) +PY +)"; then + desc="" + fi printf ' %-24s %s\n' "$name" "$desc" done } @@ -102,11 +136,12 @@ if [ "$#" -gt 0 ]; then case "$1" in bootstrap|install|remove|restore|list) COMMAND="$1" + COMMAND_EXPLICIT=1 shift ;; --*) ;; # first arg is a flag → default command (install) *) - echo "Unknown command or argument: $1" >&2 + echo "Unknown command or argument" >&2 usage >&2 exit 2 ;; @@ -117,54 +152,226 @@ fi while [ "$#" -gt 0 ]; do case "$1" in --marketplace) - MARKETPLACE="${2:?--marketplace requires a name (use 'list')}" + nddev::require_option_once "$SEEN_MARKETPLACE" "$1" || exit 2 + nddev::require_option_value "$1" "${2-}" || exit 2 + MARKETPLACE="$2" + SEEN_MARKETPLACE=1 shift 2 ;; --target) - TARGET_OVERRIDE="${2:?--target requires a directory path}" + nddev::require_option_once "$SEEN_TARGET" "$1" || exit 2 + nddev::require_option_value "$1" "${2-}" || exit 2 + TARGET_OVERRIDE="$2" + SEEN_TARGET=1 shift 2 ;; --platform) - PLATFORM="${2:?--platform requires one of macos|ubuntu}" + nddev::require_option_once "$SEEN_PLATFORM" "$1" || exit 2 + nddev::require_option_value "$1" "${2-}" || exit 2 + PLATFORM="$2" + SEEN_PLATFORM=1 shift 2 ;; --apply) + nddev::require_option_once "$SEEN_APPLY" "$1" || exit 2 APPLY=1 + SEEN_APPLY=1 shift ;; --plan | --dry-run) + nddev::require_option_once "$SEEN_PLAN" "$1" || exit 2 APPLY=0 + SEEN_PLAN=1 shift ;; --keep-backup) - export NDDEV_BACKUPS_DIR="${2:?--keep-backup requires a directory path}" + nddev::require_option_once "$SEEN_KEEP_BACKUP" "$1" || exit 2 + nddev::require_option_value "$1" "${2-}" || exit 2 + export NDDEV_BACKUPS_DIR="$2" + SEEN_KEEP_BACKUP=1 shift 2 ;; --slot) - SLOT="${2:?--slot requires a number 0-9}" + nddev::require_option_once "$SEEN_SLOT" "$1" || exit 2 + nddev::require_option_value "$1" "${2-}" || exit 2 + SLOT="$2" + SEEN_SLOT=1 shift 2 ;; + --adopt-unmanaged) + nddev::require_option_once "$SEEN_ADOPT" "$1" || exit 2 + ADOPT_UNMANAGED=1 + SEEN_ADOPT=1 + shift + ;; + --allow-target-relocation) + nddev::require_option_once "$SEEN_RELOCATION" "$1" || exit 2 + ALLOW_TARGET_RELOCATION=1 + SEEN_RELOCATION=1 + shift + ;; --backups) - COMMAND="list-backups" + nddev::require_option_once "$SEEN_BACKUPS" "$1" || exit 2 + SEEN_BACKUPS=1 shift ;; -l | --list) - list_marketplaces - exit 0 + nddev::require_option_once "$SEEN_LIST" "$1" || exit 2 + if [ "$COMMAND_EXPLICIT" -eq 1 ] && [ "$COMMAND" != "list" ]; then + nddev::log "error" "$1 cannot replace the explicit '$COMMAND' command" + exit 2 + fi + COMMAND="list" + COMMAND_EXPLICIT=1 + SEEN_LIST=1 + shift ;; -h | --help) usage exit 0 ;; *) - echo "Unknown argument: $1" >&2 + echo "Unknown argument" >&2 usage >&2 exit 2 ;; esac done +# Reject every syntactically accepted but command-inapplicable option. Silent +# ignores are unsafe for an installer because they create false expectations. +INVALID_OPTIONS="" +nddev::reject_seen_option() { + local seen=$1 option=$2 + if [ "$seen" -eq 1 ]; then + INVALID_OPTIONS="${INVALID_OPTIONS}${INVALID_OPTIONS:+, }$option" + fi +} + +if [ "$SEEN_APPLY" -eq 1 ] && [ "$SEEN_PLAN" -eq 1 ]; then + nddev::log "error" "--apply and --plan/--dry-run are mutually exclusive" + exit 2 +fi + +case "$COMMAND" in + bootstrap) + nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_TARGET" --target + nddev::reject_seen_option "$SEEN_KEEP_BACKUP" --keep-backup + nddev::reject_seen_option "$SEEN_SLOT" --slot + nddev::reject_seen_option "$SEEN_ADOPT" --adopt-unmanaged + nddev::reject_seen_option "$SEEN_RELOCATION" --allow-target-relocation + nddev::reject_seen_option "$SEEN_BACKUPS" --backups + ;; + install) + nddev::reject_seen_option "$SEEN_KEEP_BACKUP" --keep-backup + nddev::reject_seen_option "$SEEN_SLOT" --slot + nddev::reject_seen_option "$SEEN_RELOCATION" --allow-target-relocation + nddev::reject_seen_option "$SEEN_BACKUPS" --backups + ;; + remove) + nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_PLATFORM" --platform + nddev::reject_seen_option "$SEEN_SLOT" --slot + nddev::reject_seen_option "$SEEN_ADOPT" --adopt-unmanaged + nddev::reject_seen_option "$SEEN_RELOCATION" --allow-target-relocation + nddev::reject_seen_option "$SEEN_BACKUPS" --backups + ;; + restore) + nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_PLATFORM" --platform + nddev::reject_seen_option "$SEEN_KEEP_BACKUP" --keep-backup + nddev::reject_seen_option "$SEEN_ADOPT" --adopt-unmanaged + nddev::reject_seen_option "$SEEN_BACKUPS" --backups + ;; + list) + nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_TARGET" --target + nddev::reject_seen_option "$SEEN_PLATFORM" --platform + nddev::reject_seen_option "$SEEN_APPLY" --apply + nddev::reject_seen_option "$SEEN_PLAN" --plan/--dry-run + nddev::reject_seen_option "$SEEN_KEEP_BACKUP" --keep-backup + nddev::reject_seen_option "$SEEN_SLOT" --slot + nddev::reject_seen_option "$SEEN_ADOPT" --adopt-unmanaged + nddev::reject_seen_option "$SEEN_RELOCATION" --allow-target-relocation + ;; +esac +if [ -n "$INVALID_OPTIONS" ]; then + nddev::log "error" "option(s) not valid for '$COMMAND': $INVALID_OPTIONS" + exit 2 +fi +if [ "$COMMAND" = "list" ] && [ "$SEEN_BACKUPS" -eq 1 ]; then + COMMAND="list-backups" +fi + +if [ "$ALLOW_TARGET_RELOCATION" -eq 1 ]; then + if [ "$COMMAND" != "restore" ] || [ -z "$TARGET_OVERRIDE" ]; then + nddev::log "error" "--allow-target-relocation requires restore with an explicit --target" + exit 2 + fi +fi +if [ "$ADOPT_UNMANAGED" -eq 1 ]; then + if [ -z "$TARGET_OVERRIDE" ]; then + nddev::log "error" "--adopt-unmanaged requires an explicit --target " + exit 2 + fi + if [ -L "$TARGET_OVERRIDE" ] || [ ! -d "$TARGET_OVERRIDE" ]; then + nddev::log "error" "--adopt-unmanaged target must be an existing real directory" + exit 2 + fi +fi + +# Required operands and finite option domains are CLI grammar, so reject them +# before consulting Python or any machine-local build/.env state. +case "$COMMAND" in + install) + if [ -z "$MARKETPLACE" ]; then + nddev::log "error" "install requires --marketplace (use 'list' to see options)" + exit 2 + fi + if ! printf '%s\n' "$MARKETPLACE" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then + nddev::log "error" "invalid marketplace name" + exit 2 + fi + case "$PLATFORM" in auto | macos | ubuntu) ;; *) nddev::log "error" "unsupported platform (expected macos|ubuntu)"; exit 2 ;; esac + ;; + restore) + if [ -z "$SLOT" ]; then + nddev::log "error" "restore requires --slot (0-9). Use 'list --backups' to see options." + exit 2 + fi + case "$SLOT" in [0-9]) ;; *) nddev::log "error" "--slot must be a single digit 0-9"; exit 2 ;; esac + ;; +esac + +# Bootstrap is independent of build/.env. Dispatch it after the complete CLI +# grammar check, but before this wrapper requires Python or loads project +# configuration. The bootstrap entry point performs its own prerequisite +# checks after parsing its CLI. +if [ "$COMMAND" = "bootstrap" ]; then + BOOTSTRAP="$SCRIPT_DIR/bootstrap.sh" + if [ ! -x "$BOOTSTRAP" ]; then + nddev::log "error" "Missing bootstrap script: $BOOTSTRAP" + exit 2 + fi + bootstrap_args=(--platform "$PLATFORM") + [ "$APPLY" -eq 1 ] && bootstrap_args+=(--apply) || bootstrap_args+=(--plan) + exec "$BOOTSTRAP" "${bootstrap_args[@]}" +fi + +# Every remaining command uses isolated Python helpers. Plain marketplace +# listing needs no build/.env; target-mutating and backup-listing commands load +# only the two documented path keys at this layer. +nddev::require_cmd python3 required || exit 1 +if [ "$COMMAND" = "list" ]; then + list_marketplaces + exit 0 +fi +nddev::load_env paths-only || exit 1 + export NDDEV_DRY_RUN=$((1 - APPLY)) +export NDDEV_ADOPT_UNMANAGED="$ADOPT_UNMANAGED" +export NDDEV_ALLOW_TARGET_RELOCATION="$ALLOW_TARGET_RELOCATION" # ─── Resolve target directory ──────────────────────────────────────────── # Precedence: --target flag > ZCODE_TARGET (.env, already loaded) > ~/.zcode. @@ -174,15 +381,10 @@ elif [ -n "${ZCODE_TARGET:-}" ]; then export NDDEV_TARGET="$ZCODE_TARGET" fi -# ─── Handle 'list' command ─────────────────────────────────────────────── -if [ "$COMMAND" = "list" ]; then - list_marketplaces - exit 0 -fi - # ─── Handle 'list-backups' command ─────────────────────────────────────── if [ "$COMMAND" = "list-backups" ]; then backups_dir="${NDDEV_BACKUPS_DIR:-${ZCODE_BACKUPS_DIR:-$HOME/.zcode-backups}}" + backups_dir="$(nddev::validate_directory_endpoint "backup root" "$backups_dir")" || exit 2 nddev::section "Backups ($backups_dir)" if [ ! -d "$backups_dir" ]; then nddev::log "info" "no backups directory" @@ -190,17 +392,84 @@ if [ "$COMMAND" = "list-backups" ]; then fi local_found=0 for d in "$backups_dir"/*/; do - [ -d "$d" ] || continue + [ -d "$d" ] && [ ! -L "$d" ] || continue local_found=1 name="$(basename "$d")" + slot_name="${name%%-*}" + backup_version="${name#*-}" + backup_version="${backup_version%-old.zcode}" + if [ "$name" != "$slot_name-$backup_version-old.zcode" ] \ + || ! { case "$slot_name" in [0-9]) true ;; *) false ;; esac; } \ + || { [ "$backup_version" != "unmanaged" ] && ! nddev::is_semver "$backup_version"; }; then + printf ' [redacted-invalid-name] type=invalid-backup-name\n' + continue + fi bv="$d/BUILD-VERSION" - if [ -f "$bv" ]; then - ver="$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('build_version','?'))" "$bv" 2>/dev/null || echo '?')" - stamp="$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('installed_at','?'))" "$bv" 2>/dev/null || echo '?')" + if [ -f "$bv" ] && [ ! -L "$bv" ]; then + if ver="$(nddev::stamp_version "$d" 2>/dev/null)"; then + stamp="$(python3 -I -c "import json,sys; print(json.load(open(sys.argv[1], encoding='utf-8'))['installed_at'])" "$bv" 2>/dev/null)" || { + printf ' %s type=invalid-managed-stamp\n' "$name" + continue + } + printf ' %s type=managed build=%s installed=%s\n' "$name" "$ver" "$stamp" + else + printf ' %s type=invalid-managed-stamp\n' "$name" + fi + elif [ -f "$d/NDDEV-BACKUP.json" ] && [ ! -L "$d/NDDEV-BACKUP.json" ]; then + summary="$(python3 -I - "$d" "$NDDEV_SEMVER_PATTERN" <<'PY' +import json +import os +import re +import sys +import datetime as dt + +root = os.path.realpath(sys.argv[1]) +semver = re.compile(sys.argv[2]) +try: + with open(os.path.join(root, "NDDEV-BACKUP.json"), encoding="utf-8") as stream: + data = json.load(stream) +except (OSError, json.JSONDecodeError): + raise SystemExit(1) +payload_name = data.get("payload") +original = data.get("original_target") +installer_build = data.get("installer_build") +created_at = data.get("created_at") +if ( + data.get("schema") != 1 + or data.get("type") != "adopted-unmanaged" + or payload_name != "payload" + or not isinstance(original, str) + or any(ord(char) < 32 or ord(char) == 127 for char in original) + or not isinstance(installer_build, str) + or not semver.fullmatch(installer_build) + or not isinstance(created_at, str) +): + raise SystemExit(1) +try: + parsed = dt.datetime.fromisoformat(created_at.replace("Z", "+00:00")) +except ValueError: + raise SystemExit(1) +if parsed.tzinfo is None or parsed.utcoffset() != dt.timedelta(0): + raise SystemExit(1) +payload = os.path.realpath(os.path.join(root, payload_name)) +if ( + os.path.dirname(payload) != root + or not os.path.isdir(payload) + or os.path.islink(payload) + or not os.path.isabs(original) + or os.path.realpath(original) != original +): + raise SystemExit(1) +print( + f"type=adopted-unmanaged build={installer_build} " + f"created={created_at} target={original}" +) +PY +)" || summary="type=invalid-adoption-envelope" + printf ' %s %s\n' "$name" "$summary" else - ver="?"; stamp="?" + printf ' %s type=invalid-or-unmanaged\n' "$name" fi - printf ' %s build=%s installed=%s\n' "$name" "$ver" "$stamp" done [ "$local_found" -eq 0 ] && nddev::log "info" "no backups found" exit 0 @@ -211,159 +480,39 @@ if [ "$COMMAND" = "restore" ]; then # shellcheck source=lib/build.sh . "$LIB_DIR/build.sh" - if [ -z "$SLOT" ]; then - nddev::log "error" "restore requires --slot (0-9). Use 'list --backups' to see options." - exit 2 - fi - if ! printf '%s' "$SLOT" | grep -qE '^[0-9]$'; then - nddev::log "error" "--slot must be a single digit 0-9 (got: $SLOT)" - exit 2 - fi - - backups_dir="${NDDEV_BACKUPS_DIR:-${ZCODE_BACKUPS_DIR:-$HOME/.zcode-backups}}" - # Find the backup directory matching this slot (N-*-old.zcode). - backup_dir="$(find "$backups_dir" -maxdepth 1 -name "${SLOT}-*-old.zcode" -print -quit 2>/dev/null || true)" - if [ -z "$backup_dir" ] || [ ! -d "$backup_dir" ]; then - nddev::log "error" "no backup found in slot $SLOT (looked for ${SLOT}-*-old.zcode in $backups_dir)" - nddev::log "info" "available backups:" - for d in "$backups_dir"/*/; do - [ -d "$d" ] && nddev::log "info" " $(basename "$d")" - done - exit 1 - fi - - nddev::section "Restore from backup slot $SLOT" - nddev::log "info" "backup: $backup_dir" - nddev::log "info" "target: $ZCODE_HOME" - nddev::log "info" "mode: $([ "$APPLY" -eq 1 ] && echo 'APPLY' || echo 'PLAN (dry-run)')" - - # C2: Safety guard — refuse to overwrite a target that is not one of ours - # (no BUILD-VERSION), same as the 'remove' command. Prevents accidental - # destruction of a non-nddev directory via --target. - if [ -d "$ZCODE_HOME" ] && [ ! -f "$ZCODE_HOME/BUILD-VERSION" ]; then - nddev::log "error" "refusing to restore: $ZCODE_HOME has no BUILD-VERSION (not an nddev-zcode-app install). Choose an empty target or an existing stamped installation." - exit 1 - fi - - # C1: Copy the restore source to a temp dir BEFORE any backup/destructive - # operation. The pre-restore backup_current call below may reuse the source's - # slot (when all 10 are full) and delete it — operating from a temp copy makes - # that harmless. The temp dir is cleaned up on exit. - restore_source="" - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - restore_source="$(mktemp -d -t nddev-restore.XXXXXX)" - cp -R "$backup_dir" "$restore_source/zcode-backup" - restore_source="$restore_source/zcode-backup" - # Re-validate the copy exists before proceeding. - if [ ! -d "$restore_source" ]; then - nddev::log "error" "failed to stage restore source — aborting before any destructive operation" - exit 1 - fi - else - restore_source="$backup_dir" - fi - # Cleanup the temp dir on exit (only in APPLY mode, only if we created it). - # shellcheck disable=SC2317,SC2329 # invoked indirectly by the EXIT trap below. - _restore_cleanup() { - local tmp_parent - tmp_parent="$(dirname "$restore_source")" - case "$tmp_parent" in - /tmp/nddev-restore.*|*/T/nddev-restore.*) rm -rf "$tmp_parent" ;; - esac - } - trap '_restore_cleanup' EXIT - - # Safety: back up the existing target before overwriting it (if it exists). - # C3: Do NOT silence failures — a failed backup must abort the restore. - if [ -d "$ZCODE_HOME" ]; then - nddev::log "warn" "target exists — it will be backed up before restore" - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - ZCODE_HOME="$ZCODE_HOME" BACKUPS_DIR="$backups_dir" \ - NDDEV_DRY_RUN=0 nddev::backup_current - fi - fi - - # C1: Re-validate the staged source still exists (belt-and-suspenders after - # the backup_current call that may have reused a slot). - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ] && [ ! -d "$restore_source" ]; then - nddev::log "error" "restore source was lost during pre-restore backup — aborting (target untouched)" - exit 1 - fi - - # Clear the target, then copy the (staged) backup wholesale. - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] rm -rf %q\n' "$ZCODE_HOME" - printf '[DRY-RUN] cp -R %q %q\n' "${restore_source}" "${ZCODE_HOME}" - else - rm -rf "$ZCODE_HOME" - cp -R "$restore_source" "$ZCODE_HOME" - nddev::log "ok" "restored $ZCODE_HOME from $(basename "$backup_dir")" - fi + # Keep this as a simple command: putting a Bash function in an OR-list + # disables errexit throughout its body and can bypass transaction rollback. + nddev::restore_backup_slot "$SLOT" + nddev::log "ok" "restored $ZCODE_HOME from $(basename "$NDDEV_RESTORE_SOURCE")" nddev::section "Restore complete" bv="$ZCODE_HOME/BUILD-VERSION" - if [ -f "$bv" ]; then - python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(f'[ok] build {d.get(\"build_version\",\"?\")} from {d.get(\"installed_at\",\"?\")}')" "$bv" 2>/dev/null || true + if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ] && [ -f "$bv" ]; then + python3 -I -c "import json,sys; d=json.load(open(sys.argv[1])); print(f'[ok] build {d[\"build_version\"]} from {d[\"installed_at\"]}')" "$bv" fi exit 0 fi -# ─── Handle 'bootstrap' command ────────────────────────────────────────── -if [ "$COMMAND" = "bootstrap" ]; then - BOOTSTRAP="$SCRIPT_DIR/bootstrap.sh" - if [ ! -x "$BOOTSTRAP" ]; then - nddev::log "error" "Missing bootstrap script: $BOOTSTRAP" - exit 2 - fi - # Build the arg array safely (no unquoted word-splitting). - bootstrap_args=() - [ -n "$PLATFORM" ] && bootstrap_args+=(--platform "$PLATFORM") - [ "$APPLY" -eq 1 ] && bootstrap_args+=(--apply) || bootstrap_args+=(--plan) - exec "$BOOTSTRAP" "${bootstrap_args[@]}" -fi - # ─── Handle 'remove' command ───────────────────────────────────────────── if [ "$COMMAND" = "remove" ]; then # shellcheck source=lib/build.sh . "$LIB_DIR/build.sh" nddev::section "nddev-zcode-app — remove" nddev::log "info" "mode: $([ "$APPLY" -eq 1 ] && echo 'APPLY' || echo 'PLAN (dry-run)')" - nddev::log "info" "target: $ZCODE_HOME" - - if [ ! -d "$ZCODE_HOME" ]; then - nddev::log "info" "nothing to remove: $ZCODE_HOME does not exist" - exit 0 - fi - - # Check it's one of ours (has BUILD-VERSION) before deleting. - if [ ! -f "$ZCODE_HOME/BUILD-VERSION" ]; then - nddev::log "error" "refusing to remove: $ZCODE_HOME has no BUILD-VERSION (not an nddev-zcode-app install). Only stamped installations can be removed." - exit 1 - fi - # Back up first, then delete. - nddev::backup_current - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] rm -rf %q\n' "$ZCODE_HOME" - else - rm -rf "$ZCODE_HOME" - nddev::log "ok" "removed: $ZCODE_HOME" - fi + # Keep this as a simple command so critical failures trigger the EXIT trap. + nddev::remove_managed_target + [ -n "$NDDEV_BACKUP_PATH" ] && nddev::log "ok" "removed target into backup: $NDDEV_BACKUP_PATH" exit 0 fi # ─── Handle 'install' command ──────────────────────────────────────────── -if [ "$MARKETPLACE" = "" ]; then - nddev::log "error" "install requires --marketplace (use 'list' to see options)" - exit 2 -fi - # Resolve platform. if [ "$PLATFORM" = "auto" ]; then PLATFORM="$(nddev::detect_platform)" || exit 1 fi if [ "$PLATFORM" != "macos" ] && [ "$PLATFORM" != "ubuntu" ]; then - nddev::log "error" "Unsupported platform: $PLATFORM (expected macos|ubuntu)" + nddev::log "error" "unsupported platform (expected macos|ubuntu)" exit 2 fi @@ -382,13 +531,8 @@ fi nddev::section "nddev-zcode-app installer" nddev::log "info" "mode: $([ "$APPLY" -eq 1 ] && echo 'APPLY' || echo 'PLAN (dry-run)')" nddev::log "info" "platform: $PLATFORM" -nddev::log "info" "target: $ZCODE_HOME" nddev::log "info" "repo root: $ROOT" -# Validate prerequisites. -nddev::require_cmd git required || exit 1 -nddev::require_cmd python3 required || exit 1 - # Select and validate the marketplace (sets SOURCE_DIR). nddev::select_marketplace "$MARKETPLACE" || exit 1 diff --git a/cli-tools/scripts/lib/build.sh b/cli-tools/scripts/lib/build.sh index 0a2ab55..955e2ee 100644 --- a/cli-tools/scripts/lib/build.sh +++ b/cli-tools/scripts/lib/build.sh @@ -1,24 +1,64 @@ #!/usr/bin/env bash -# Shared build/backup/restore logic. Sourced by the macos and ubuntu runners -# after common.sh and version.sh. -# -# Each marketplace is a SELF-CONTAINED setup: it owns its AGENTS.md, config -# templates, mcp/hooks, user-scope skills/commands/agents, and plugins. The -# installer selects ONE marketplace (--marketplace ) and builds a clean -# ~/.zcode entirely from that marketplace's directory. - -# Path constants. SOURCE_DIR is resolved by nddev::select_marketplace() below. -# Resolution order for the install target: CLI flag --target > ZCODE_TARGET -# (from build/.env) > NDDEV_TARGET (legacy test override) > ~/.zcode (standard). +# Transactional build, backup, restore, and verification logic. + ZCODE_HOME="${NDDEV_TARGET:-${ZCODE_TARGET:-$HOME/.zcode}}" BACKUPS_DIR="${NDDEV_BACKUPS_DIR:-${ZCODE_BACKUPS_DIR:-$HOME/.zcode-backups}}" MARKETPLACES_ROOT="$(nddev::repo_root)/zcode_tools/marketplaces" SOURCE_DIR="" RESTORE_SCRIPT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)/restore.sh" -# --- Marketplace selection ------------------------------------------------ +NDDEV_BACKUP_PATH="" +NDDEV_STAGE_PATH="" +NDDEV_STAGE_IDENTITY="" +NDDEV_LOCK_PATHS=() +NDDEV_ORIGINAL_TARGET_IDENTITY="" +NDDEV_ROLLBACK_BACKUP="" +NDDEV_ROLLBACK_IDENTITY="" +NDDEV_ROLLBACK_ENVELOPE="" +NDDEV_ROLLBACK_ENVELOPE_IDENTITY="" +NDDEV_REPLACED_BACKUP_HOLD="" +NDDEV_REPLACED_BACKUP_HOLD_IDENTITY="" +NDDEV_REPLACED_BACKUP_ORIGINAL="" +NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY="" +NDDEV_LIVE_SWAPPED=0 +NDDEV_LIVE_RENAME_PENDING=0 +NDDEV_LIVE_SOURCE_ID="" +NDDEV_LIVE_IDENTITY="" +NDDEV_FINISHING=0 +NDDEV_RESTORE_SOURCE="" + +nddev::prepare_paths() { + local target backups + target="$(nddev::validate_directory_endpoint "install target" "$ZCODE_HOME")" || return 2 + backups="$(nddev::validate_directory_endpoint "backup root" "$BACKUPS_DIR")" || return 2 + if ! nddev::validate_disjoint_roots "$target" "$backups"; then + nddev::log "error" "target and backup roots must be disjoint" + return 2 + fi + ZCODE_HOME="$target" + BACKUPS_DIR="$backups" + export NDDEV_TARGET="$ZCODE_HOME" + export NDDEV_BACKUPS_DIR="$BACKUPS_DIR" + + local target_parent backup_parent parent + target_parent="$(dirname "$ZCODE_HOME")" + backup_parent="$(dirname "$BACKUPS_DIR")" + for parent in "$target_parent" "$backup_parent"; do + if [ -L "$parent" ] || [ ! -d "$parent" ]; then + nddev::log "error" "transaction parent must be an existing real directory: $parent" + return 2 + fi + done + + if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + nddev::ensure_dir "$BACKUPS_DIR" || return 1 + fi + if ! nddev::same_filesystem "$ZCODE_HOME" "$BACKUPS_DIR"; then + nddev::log "error" "target and backup root must be on the same filesystem for atomic rollback" + return 1 + fi +} -# Validate that a marketplace directory exists and is self-contained. Exits on failure. nddev::validate_marketplace() { local mp_dir=$1 local required=( @@ -28,26 +68,117 @@ nddev::validate_marketplace() { "v2-config.template.json" "v2-setting.template.json" ) - local f - for f in "${required[@]}"; do - if [ ! -f "$mp_dir/$f" ]; then - nddev::log "error" "marketplace '$(basename "$mp_dir")' is not self-contained: missing $f" + if [ -L "$mp_dir" ]; then + nddev::log "error" "marketplace must not be a symlink: $mp_dir" + return 1 + fi + nddev::assert_safe_tree "$mp_dir" || return 1 + local file + for file in "${required[@]}"; do + if [ ! -f "$mp_dir/$file" ] || [ -L "$mp_dir/$file" ]; then + nddev::log "error" "marketplace '$(basename "$mp_dir")' is not self-contained: missing safe $file" return 1 fi done - return 0 + for file in marketplace.json cli-config.template.json v2-config.template.json v2-setting.template.json; do + nddev::validate_json "$mp_dir/$file" >/dev/null || { + nddev::log "error" "marketplace contains invalid JSON: $file" + return 1 + } + done + python3 -I - "$mp_dir" "$NDDEV_SEMVER_PATTERN" <<'PY' +import json +import os +import re +import stat +import sys + +root = os.path.realpath(sys.argv[1]) +semver = re.compile(sys.argv[2]) +expected_marketplace = os.path.basename(root) +marketplace_name = re.compile(r"[a-z0-9][a-z0-9-]*") +plugin_name = re.compile(r"[a-z0-9][a-z0-9._-]{0,127}") +with open(os.path.join(root, "marketplace.json"), encoding="utf-8") as stream: + marketplace = json.load(stream) +if marketplace.get("name") != expected_marketplace or not marketplace_name.fullmatch(expected_marketplace): + raise SystemExit("marketplace manifest name must match its safe directory name") +plugins = marketplace.get("plugins") +if not isinstance(plugins, list): + raise SystemExit("marketplace plugins must be a JSON array") +seen_names = set() +seen_sources = set() +declared_directories = set() +for entry in plugins: + if not isinstance(entry, dict): + raise SystemExit("marketplace plugin entries must be JSON objects") + name = entry.get("name") + source = entry.get("source") + version = entry.get("version") + if not isinstance(name, str) or not plugin_name.fullmatch(name): + raise SystemExit("marketplace plugin name is unsafe") + expected_source = f"./plugins/{name}" + if source != expected_source: + raise SystemExit("marketplace plugin source must be exactly ./plugins/") + if name in seen_names or source in seen_sources: + raise SystemExit("marketplace plugin names and sources must be unique") + if not isinstance(version, str) or not semver.fullmatch(version): + raise SystemExit("marketplace plugin version must be SemVer") + seen_names.add(name) + seen_sources.add(source) + declared_directories.add(name) + + plugin_root = os.path.join(root, "plugins", name) + manifest_path = os.path.join(plugin_root, ".zcode-plugin", "plugin.json") + for path, role in ((plugin_root, "plugin directory"), (manifest_path, "plugin manifest")): + try: + metadata = os.lstat(path) + except FileNotFoundError as exc: + raise SystemExit(f"missing {role}") from exc + expected_type = stat.S_ISDIR if role == "plugin directory" else stat.S_ISREG + if stat.S_ISLNK(metadata.st_mode) or not expected_type(metadata.st_mode): + raise SystemExit(f"unsafe {role}") + with open(manifest_path, encoding="utf-8") as stream: + manifest = json.load(stream) + if not isinstance(manifest, dict): + raise SystemExit("plugin manifest must be a JSON object") + if manifest.get("name") != name: + raise SystemExit("plugin manifest name must match marketplace entry") + if manifest.get("version") != version or not semver.fullmatch(version): + raise SystemExit("plugin manifest version must match marketplace entry") + +plugins_root = os.path.join(root, "plugins") +if os.path.lexists(plugins_root): + metadata = os.lstat(plugins_root) + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode): + raise SystemExit("marketplace plugins endpoint is unsafe") + actual_directories = { + entry.name + for entry in os.scandir(plugins_root) + if entry.is_dir(follow_symlinks=False) + } + if actual_directories != declared_directories: + raise SystemExit("marketplace plugin directories must match declared plugins exactly") +elif declared_directories: + raise SystemExit("marketplace declares plugins without a plugins directory") +PY } -# Resolve SOURCE_DIR to the selected marketplace directory. $1 = marketplace name. nddev::select_marketplace() { - local mp_name=$1 - local mp_dir="$MARKETPLACES_ROOT/$mp_name" - if [ ! -d "$mp_dir" ]; then - nddev::log "error" "marketplace not found: $mp_name (looked in $mp_dir)" + local mp_name=$1 mp_dir + if ! printf '%s\n' "$mp_name" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then + nddev::log "error" "invalid marketplace name" + return 2 + fi + mp_dir="$MARKETPLACES_ROOT/$mp_name" + if [ ! -d "$mp_dir" ] || [ -L "$mp_dir" ]; then + nddev::log "error" "marketplace not found: $mp_name" nddev::log "info" "available marketplaces:" - local d - for d in "$MARKETPLACES_ROOT"/*/; do - [ -d "$d" ] && nddev::log "info" " - $(basename "$d")" + local directory available_name + for directory in "$MARKETPLACES_ROOT"/*/; do + [ -d "$directory" ] && [ ! -L "$directory" ] || continue + available_name="$(basename "$directory")" + printf '%s\n' "$available_name" | grep -qE '^[a-z0-9][a-z0-9-]*$' || continue + nddev::log "info" " - $available_name" done return 1 fi @@ -56,55 +187,670 @@ nddev::select_marketplace() { nddev::log "info" "selected marketplace: $mp_name ($mp_dir)" } -# --- Backup -------------------------------------------------------------- +# --- Transaction lifecycle ------------------------------------------------- + +nddev::acquire_lock() { + local parent name target_lock backup_lock lock + parent="$(dirname "$ZCODE_HOME")" + name="$(basename "$ZCODE_HOME")" + target_lock="$parent/.${name}.nddev-lock" + backup_lock="$BACKUPS_DIR/.nddev-backups-lock" + nddev::assert_direct_child "$parent" "$target_lock" || return 1 + nddev::assert_direct_child "$BACKUPS_DIR" "$backup_lock" || return 1 + NDDEV_LOCK_PATHS=() + + # Canonical lexical ordering prevents deadlock when the same roots are used + # by concurrent processes in different combinations. + while IFS= read -r lock; do + [ -n "$lock" ] || continue + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + printf '[DRY-RUN] acquire lock %q\n' "$lock" + NDDEV_LOCK_PATHS+=("$lock") + continue + fi + if ! mkdir -m 700 "$lock" 2>/dev/null; then + nddev::log "error" "another installer transaction holds the lock: $lock" + nddev::log "error" "inspect owner metadata manually only after validating $lock/owner" + nddev::release_lock + return 1 + fi + NDDEV_LOCK_PATHS+=("$lock") + if ! printf 'pid=%s\nstarted_at=%s\ntarget=%s\nbackups=%s\n' \ + "$$" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$ZCODE_HOME" "$BACKUPS_DIR" > "$lock/owner" \ + || ! chmod 600 "$lock/owner"; then + nddev::log "error" "failed to initialize transaction lock: $lock" + nddev::release_lock + return 1 + fi + done < <(printf '%s\n%s\n' "$target_lock" "$backup_lock" | LC_ALL=C sort -u) +} + +nddev::release_lock() { + local index lock errors=0 + local -a remaining=() + for ((index=${#NDDEV_LOCK_PATHS[@]} - 1; index >= 0; index--)); do + lock="${NDDEV_LOCK_PATHS[$index]}" + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + printf '[DRY-RUN] release lock %q\n' "$lock" + elif [ -d "$lock" ] && [ ! -L "$lock" ]; then + if ! nddev::remove_direct_child_tree "$(dirname "$lock")" "$lock"; then + nddev::log "error" "transaction state is preserved, but lock cleanup failed: $lock" + remaining+=("$lock") + errors=1 + fi + elif [ -e "$lock" ] || [ -L "$lock" ]; then + nddev::log "error" "transaction lock endpoint changed type; inspect manually: $lock" + remaining+=("$lock") + errors=1 + fi + done + # Bash 3.2 treats an empty "${array[@]}" expansion as an unbound variable + # under `set -u`. Reset first and copy only when an element exists. + NDDEV_LOCK_PATHS=() + if [ "${#remaining[@]}" -gt 0 ]; then + NDDEV_LOCK_PATHS=("${remaining[@]}") + fi + return "$errors" +} -# Back up the current ~/.zcode into ~/.zcode-backups/--old.zcode. -# Ten slots (0-9); reusing a slot overwrites the old backup regardless of its -# version, so the pool never exceeds 10 directories. Safe to call when ~/.zcode -# does not exist (no-op log). -nddev::backup_current() { - if [ ! -d "$ZCODE_HOME" ]; then - nddev::log "info" "no existing ~/.zcode to back up (fresh install)" +nddev::reconcile_live_rename() { + local stage_identity="" live_identity="" + [ "$NDDEV_LIVE_RENAME_PENDING" -eq 1 ] || return 0 + if [ -n "$NDDEV_STAGE_PATH" ] && [ -e "$NDDEV_STAGE_PATH" ] && [ ! -L "$NDDEV_STAGE_PATH" ]; then + stage_identity="$(nddev::path_identity "$NDDEV_STAGE_PATH" directory 2>/dev/null || true)" + fi + if [ -e "$ZCODE_HOME" ] && [ ! -L "$ZCODE_HOME" ]; then + live_identity="$(nddev::path_identity "$ZCODE_HOME" directory 2>/dev/null || true)" + fi + if [ "$live_identity" = "$NDDEV_LIVE_SOURCE_ID" ] \ + && { [ ! -e "$NDDEV_STAGE_PATH" ] && [ ! -L "$NDDEV_STAGE_PATH" ]; }; then + NDDEV_LIVE_SWAPPED=1 + NDDEV_LIVE_RENAME_PENDING=0 + NDDEV_LIVE_IDENTITY="$NDDEV_LIVE_SOURCE_ID" + NDDEV_STAGE_PATH="" return 0 fi + if [ "$stage_identity" = "$NDDEV_LIVE_SOURCE_ID" ]; then + # The exclusive rename did not consume the stage. Any destination that + # appeared belongs to another actor and must remain untouched. + NDDEV_LIVE_SWAPPED=0 + NDDEV_LIVE_RENAME_PENDING=0 + return 0 + fi + nddev::log "error" "live rename state is ambiguous; preserving transaction paths and locks for inspection" + return 1 +} - local current_version backup_name target slot - current_version="$(nddev::current_version)" - slot="$(nddev::backup_slot)" - backup_name="${slot}-${current_version}-old.zcode" - target="$BACKUPS_DIR/$backup_name" +nddev::transaction_identity_preflight() { + local success=$1 hold_payload="$NDDEV_REPLACED_BACKUP_HOLD/original" + [ "${NDDEV_DRY_RUN:-1}" -eq 0 ] || return 0 - nddev::section "Backup current ~/.zcode" - nddev::log "info" "current build version: $current_version" - nddev::log "info" "backup target: $target" + if [ "$NDDEV_LIVE_SWAPPED" -eq 1 ] \ + && ! nddev::identity_matches "$ZCODE_HOME" directory "$NDDEV_LIVE_IDENTITY"; then + nddev::log "error" "live target identity changed; preserving recovery state and transaction locks" + return 1 + fi + if [ -n "$NDDEV_STAGE_PATH" ]; then + if [ ! -e "$NDDEV_STAGE_PATH" ] && [ ! -L "$NDDEV_STAGE_PATH" ]; then + nddev::log "error" "staging endpoint disappeared; preserving recovery state and transaction locks" + return 1 + fi + if ! nddev::identity_matches "$NDDEV_STAGE_PATH" directory "$NDDEV_STAGE_IDENTITY"; then + nddev::log "error" "staging identity changed; preserving recovery state and transaction locks" + return 1 + fi + fi - nddev::ensure_dir "$BACKUPS_DIR" + if [ -n "$NDDEV_ROLLBACK_BACKUP" ]; then + if [ -e "$NDDEV_ROLLBACK_BACKUP" ] || [ -L "$NDDEV_ROLLBACK_BACKUP" ]; then + if ! nddev::identity_matches "$NDDEV_ROLLBACK_BACKUP" directory "$NDDEV_ROLLBACK_IDENTITY"; then + nddev::log "error" "rollback source identity changed; preserving recovery state and transaction locks" + return 1 + fi + elif [ "$NDDEV_LIVE_SWAPPED" -eq 0 ] \ + && nddev::identity_matches "$ZCODE_HOME" directory "$NDDEV_ROLLBACK_IDENTITY"; then + : # The old-target move did not occur; the original endpoint is intact. + else + nddev::log "error" "rollback source disappeared; preserving recovery state and transaction locks" + return 1 + fi + fi - # Remove any existing backup in this slot (version may differ, so the name - # may differ — match by slot prefix, not exact name). - local existing - existing="$(find "$BACKUPS_DIR" -maxdepth 1 -name "${slot}-*-old.zcode" -print -quit 2>/dev/null || true)" - if [ -n "$existing" ] && [ "$existing" != "$target" ]; then - nddev::log "warn" "slot ${slot} occupied by different backup; removing: $(basename "$existing")" - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - rm -rf "$existing" + if [ -n "$NDDEV_ROLLBACK_ENVELOPE" ]; then + if [ -e "$NDDEV_ROLLBACK_ENVELOPE" ] || [ -L "$NDDEV_ROLLBACK_ENVELOPE" ]; then + if ! nddev::identity_matches \ + "$NDDEV_ROLLBACK_ENVELOPE" directory "$NDDEV_ROLLBACK_ENVELOPE_IDENTITY"; then + nddev::log "error" "adoption envelope identity changed; preserving recovery state and transaction locks" + return 1 + fi + elif [ -z "$NDDEV_ROLLBACK_ENVELOPE_IDENTITY" ]; then + NDDEV_ROLLBACK_ENVELOPE="" + else + nddev::log "error" "adoption envelope disappeared; preserving recovery state and transaction locks" + return 1 fi - elif [ -e "$target" ]; then - nddev::log "warn" "overwriting same-version backup: $target" - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - rm -rf "$target" + fi + + if [ -n "$NDDEV_REPLACED_BACKUP_HOLD" ]; then + if [ -e "$NDDEV_REPLACED_BACKUP_HOLD" ] || [ -L "$NDDEV_REPLACED_BACKUP_HOLD" ]; then + if ! nddev::identity_matches \ + "$NDDEV_REPLACED_BACKUP_HOLD" directory "$NDDEV_REPLACED_BACKUP_HOLD_IDENTITY"; then + nddev::log "error" "backup hold identity changed; preserving recovery state and transaction locks" + return 1 + fi + if [ -e "$hold_payload" ] || [ -L "$hold_payload" ]; then + if ! nddev::identity_matches \ + "$hold_payload" directory "$NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY"; then + nddev::log "error" "held backup identity changed; preserving recovery state and transaction locks" + return 1 + fi + elif ! nddev::identity_matches \ + "$NDDEV_REPLACED_BACKUP_ORIGINAL" directory "$NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY"; then + nddev::log "error" "held backup disappeared; preserving recovery state and transaction locks" + return 1 + fi + elif [ "$success" -eq 1 ]; then + # Idempotent success cleanup may be re-entered after the hold was removed + # but before the shell cleared its in-memory paths. + NDDEV_REPLACED_BACKUP_HOLD="" + NDDEV_REPLACED_BACKUP_HOLD_IDENTITY="" + NDDEV_REPLACED_BACKUP_ORIGINAL="" + NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY="" + else + nddev::log "error" "backup hold disappeared; preserving recovery state and transaction locks" + return 1 fi fi +} - nddev::move "$ZCODE_HOME" "$target" - nddev::log "ok" "backup complete" +nddev::transaction_cleanup() { + local success=$1 rollback_complete=0 rollback_performed=0 recovery_incomplete=0 failed_tree="" errors=0 + + # A signal may arrive after the native rename completed but before the shell + # recorded it. Reconcile by inode before any rollback or deletion decision. + if ! nddev::reconcile_live_rename; then + return 1 + fi + if ! nddev::transaction_identity_preflight "$success"; then + return 1 + fi + + if [ "$success" -ne 1 ]; then + if [ -z "$NDDEV_ROLLBACK_BACKUP" ]; then + if [ "$NDDEV_LIVE_SWAPPED" -eq 1 ] && [ -d "$ZCODE_HOME" ] && [ ! -L "$ZCODE_HOME" ]; then + # A fresh install failed after its atomic rename. Restore the exact + # pre-state (no target) by moving the new tree aside before deleting it. + if failed_tree="$(mktemp -d "$(dirname "$ZCODE_HOME")/.nddev-failed.XXXXXX")" \ + && rmdir "$failed_tree" \ + && nddev::rename_noreplace \ + "$ZCODE_HOME" "$failed_tree" directory "$NDDEV_LIVE_IDENTITY"; then + rollback_complete=1 + if ! nddev::remove_direct_child_tree \ + "$(dirname "$failed_tree")" "$failed_tree" "$NDDEV_LIVE_IDENTITY"; then + nddev::log "error" "fresh-install rollback removed the live target but preserved failed state at $failed_tree" + errors=1 + fi + else + errors=1 + nddev::log "error" "failed to remove the newly committed target during fresh-install rollback: $ZCODE_HOME" + fi + elif [ "$NDDEV_LIVE_SWAPPED" -eq 1 ] \ + && { [ -e "$ZCODE_HOME" ] || [ -L "$ZCODE_HOME" ]; }; then + errors=1 + nddev::log "error" "fresh-install target changed type during rollback; inspect manually: $ZCODE_HOME" + else + # The stage was never made live, or its rename failed before visibility. + rollback_complete=1 + fi + elif [ -d "$NDDEV_ROLLBACK_BACKUP" ] && [ ! -L "$NDDEV_ROLLBACK_BACKUP" ]; then + if [ "$NDDEV_LIVE_SWAPPED" -eq 1 ] && [ -d "$ZCODE_HOME" ] && [ ! -L "$ZCODE_HOME" ]; then + if failed_tree="$(mktemp -d "$(dirname "$ZCODE_HOME")/.nddev-failed.XXXXXX")" \ + && rmdir "$failed_tree"; then + if nddev::rename_noreplace \ + "$ZCODE_HOME" "$failed_tree" directory "$NDDEV_LIVE_IDENTITY"; then + if nddev::rename_noreplace \ + "$NDDEV_ROLLBACK_BACKUP" "$ZCODE_HOME" directory "$NDDEV_ROLLBACK_IDENTITY"; then + rollback_complete=1 + rollback_performed=1 + if ! nddev::remove_direct_child_tree \ + "$(dirname "$failed_tree")" "$failed_tree" "$NDDEV_LIVE_IDENTITY"; then + nddev::log "error" "rollback restored the previous target but could not remove failed staged state: $failed_tree" + errors=1 + fi + else + errors=1 + nddev::log "error" "failed to restore the previous target from $NDDEV_ROLLBACK_BACKUP" + if [ ! -e "$ZCODE_HOME" ] && [ ! -L "$ZCODE_HOME" ]; then + if nddev::rename_noreplace \ + "$failed_tree" "$ZCODE_HOME" directory "$NDDEV_LIVE_IDENTITY"; then + nddev::log "error" "the failed new target was put back; the previous target remains recoverable at $NDDEV_ROLLBACK_BACKUP" + else + nddev::log "error" "both target states require manual recovery: $failed_tree and $NDDEV_ROLLBACK_BACKUP" + fi + fi + fi + else + errors=1 + nddev::log "error" "failed to move the new target aside for rollback: $ZCODE_HOME" + fi + else + errors=1 + nddev::log "error" "failed to reserve a same-filesystem path for rollback" + fi + elif [ ! -e "$ZCODE_HOME" ] && [ ! -L "$ZCODE_HOME" ]; then + if nddev::rename_noreplace \ + "$NDDEV_ROLLBACK_BACKUP" "$ZCODE_HOME" directory "$NDDEV_ROLLBACK_IDENTITY"; then + rollback_complete=1 + rollback_performed=1 + else + errors=1 + nddev::log "error" "failed to restore the previous target from $NDDEV_ROLLBACK_BACKUP" + fi + else + errors=1 + nddev::log "error" "rollback state is ambiguous; preserve and inspect $ZCODE_HOME and $NDDEV_ROLLBACK_BACKUP" + fi + elif [ -d "$ZCODE_HOME" ] && [ ! -L "$ZCODE_HOME" ] \ + && [ ! -e "$NDDEV_ROLLBACK_BACKUP" ] && [ ! -L "$NDDEV_ROLLBACK_BACKUP" ]; then + # The destructive move did not happen; the original target is intact. + rollback_complete=1 + else + errors=1 + nddev::log "error" "rollback source is missing or unsafe; inspect $NDDEV_ROLLBACK_BACKUP" + fi + if [ "$rollback_performed" -eq 1 ]; then + nddev::log "warn" "rolled back previous target after failed transaction" + fi + if [ "$rollback_complete" -eq 1 ] && [ -n "$NDDEV_ROLLBACK_ENVELOPE" ]; then + if [ -d "$NDDEV_ROLLBACK_ENVELOPE" ] && [ ! -L "$NDDEV_ROLLBACK_ENVELOPE" ]; then + if ! nddev::remove_direct_child_tree \ + "$BACKUPS_DIR" "$NDDEV_ROLLBACK_ENVELOPE" "$NDDEV_ROLLBACK_ENVELOPE_IDENTITY"; then + nddev::log "error" "rollback succeeded but empty adoption envelope cleanup failed: $NDDEV_ROLLBACK_ENVELOPE" + errors=1 + fi + elif [ -e "$NDDEV_ROLLBACK_ENVELOPE" ] || [ -L "$NDDEV_ROLLBACK_ENVELOPE" ]; then + nddev::log "error" "adoption envelope changed type during rollback; inspect manually: $NDDEV_ROLLBACK_ENVELOPE" + errors=1 + fi + fi + if [ -n "$NDDEV_REPLACED_BACKUP_HOLD" ] && [ -d "$NDDEV_REPLACED_BACKUP_HOLD/original" ] \ + && [ ! -L "$NDDEV_REPLACED_BACKUP_HOLD/original" ]; then + if [ "$rollback_complete" -eq 1 ] \ + && [ ! -e "$NDDEV_REPLACED_BACKUP_ORIGINAL" ] && [ ! -L "$NDDEV_REPLACED_BACKUP_ORIGINAL" ]; then + if ! nddev::rename_noreplace \ + "$NDDEV_REPLACED_BACKUP_HOLD/original" "$NDDEV_REPLACED_BACKUP_ORIGINAL" \ + directory "$NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY"; then + nddev::log "error" "failed to restore the occupied backup slot; recovery copy remains at $NDDEV_REPLACED_BACKUP_HOLD/original" + errors=1 + recovery_incomplete=1 + fi + else + nddev::log "error" "preserved occupied-slot recovery copy for manual inspection: $NDDEV_REPLACED_BACKUP_HOLD/original" + errors=1 + recovery_incomplete=1 + fi + fi + [ "$rollback_complete" -eq 1 ] || recovery_incomplete=1 + fi + + if [ -n "$NDDEV_STAGE_PATH" ]; then + if [ -d "$NDDEV_STAGE_PATH" ] && [ ! -L "$NDDEV_STAGE_PATH" ]; then + if ! nddev::remove_direct_child_tree \ + "$(dirname "$NDDEV_STAGE_PATH")" "$NDDEV_STAGE_PATH" "$NDDEV_STAGE_IDENTITY"; then + nddev::log "error" "failed to remove transaction staging tree; inspect manually: $NDDEV_STAGE_PATH" + errors=1 + fi + elif [ -e "$NDDEV_STAGE_PATH" ] || [ -L "$NDDEV_STAGE_PATH" ]; then + nddev::log "error" "transaction staging endpoint changed type; inspect manually: $NDDEV_STAGE_PATH" + errors=1 + fi + fi + if [ -n "$NDDEV_REPLACED_BACKUP_HOLD" ] && [ -d "$NDDEV_REPLACED_BACKUP_HOLD" ] \ + && [ ! -L "$NDDEV_REPLACED_BACKUP_HOLD" ]; then + # On success this discards the replaced oldest slot. On a clean rollback the + # original moved back above, leaving an empty hold directory. If rollback + # itself could not complete, preserve the hold for explicit recovery. + if [ "$success" -eq 1 ] || [ ! -e "$NDDEV_REPLACED_BACKUP_HOLD/original" ]; then + if ! nddev::remove_direct_child_tree \ + "$BACKUPS_DIR" "$NDDEV_REPLACED_BACKUP_HOLD" "$NDDEV_REPLACED_BACKUP_HOLD_IDENTITY"; then + nddev::log "error" "target commit succeeded, but obsolete backup hold cleanup failed: $NDDEV_REPLACED_BACKUP_HOLD" + errors=1 + fi + else + nddev::log "error" "preserved recovery hold after incomplete rollback: $NDDEV_REPLACED_BACKUP_HOLD" + errors=1 + fi + elif [ -n "$NDDEV_REPLACED_BACKUP_HOLD" ] \ + && { [ -e "$NDDEV_REPLACED_BACKUP_HOLD" ] || [ -L "$NDDEV_REPLACED_BACKUP_HOLD" ]; }; then + nddev::log "error" "backup hold endpoint changed type; inspect manually: $NDDEV_REPLACED_BACKUP_HOLD" + errors=1 + fi + if [ "$success" -ne 1 ] && [ "$recovery_incomplete" -eq 1 ] \ + && [ "${#NDDEV_LOCK_PATHS[@]}" -gt 0 ]; then + nddev::log "error" "transaction locks are preserved because rollback recovery is incomplete" + errors=1 + elif ! nddev::release_lock; then + nddev::log "error" "remove preserved lock directories only after verifying no installer process is active" + errors=1 + fi + if [ "$success" -eq 1 ]; then + # These paths now describe committed history, not rollback work. + NDDEV_STAGE_PATH="" + NDDEV_STAGE_IDENTITY="" + NDDEV_ROLLBACK_BACKUP="" + NDDEV_ROLLBACK_IDENTITY="" + NDDEV_ROLLBACK_ENVELOPE="" + NDDEV_ROLLBACK_ENVELOPE_IDENTITY="" + else + { [ -e "$NDDEV_STAGE_PATH" ] || [ -L "$NDDEV_STAGE_PATH" ]; } || { + NDDEV_STAGE_PATH="" + NDDEV_STAGE_IDENTITY="" + } + { [ -e "$NDDEV_ROLLBACK_BACKUP" ] || [ -L "$NDDEV_ROLLBACK_BACKUP" ]; } || { + NDDEV_ROLLBACK_BACKUP="" + NDDEV_ROLLBACK_IDENTITY="" + } + { [ -e "$NDDEV_ROLLBACK_ENVELOPE" ] || [ -L "$NDDEV_ROLLBACK_ENVELOPE" ]; } || { + NDDEV_ROLLBACK_ENVELOPE="" + NDDEV_ROLLBACK_ENVELOPE_IDENTITY="" + } + fi + { [ -e "$NDDEV_REPLACED_BACKUP_HOLD" ] || [ -L "$NDDEV_REPLACED_BACKUP_HOLD" ]; } || { + NDDEV_REPLACED_BACKUP_HOLD="" + NDDEV_REPLACED_BACKUP_HOLD_IDENTITY="" + NDDEV_REPLACED_BACKUP_ORIGINAL="" + NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY="" + } + NDDEV_ORIGINAL_TARGET_IDENTITY="" + NDDEV_LIVE_SWAPPED=0 + NDDEV_LIVE_RENAME_PENDING=0 + NDDEV_LIVE_SOURCE_ID="" + NDDEV_LIVE_IDENTITY="" + if [ "$errors" -ne 0 ]; then + if [ "$success" -eq 1 ]; then + nddev::log "error" "transaction committed safely, but housekeeping is incomplete; command reports failure" + else + nddev::log "error" "transaction cleanup is incomplete; inspect the recovery paths reported above" + fi + fi + return "$errors" +} + +nddev::transaction_abort() { + local status=$? cleanup_mode=0 + trap - EXIT INT TERM HUP + [ "$status" -ne 0 ] || status=1 + [ "$NDDEV_FINISHING" -eq 1 ] && cleanup_mode=1 + nddev::transaction_cleanup "$cleanup_mode" || true + exit "$status" +} + +nddev::begin_transaction() { + nddev::acquire_lock || return 1 + if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + trap 'nddev::transaction_abort' EXIT + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + fi } -# --- Build (lay down clean ~/.zcode from the selected marketplace) -------- +nddev::finish_transaction() { + local status + if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + # Keep signal/EXIT traps active until idempotent committed-state cleanup is + # complete. A signal in this window re-enters cleanup in success mode and + # must never roll back the verified live tree. + NDDEV_FINISHING=1 + fi + nddev::transaction_cleanup 1 + status=$? + if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + NDDEV_FINISHING=0 + trap - EXIT INT TERM HUP + fi + return "$status" +} + +nddev::create_stage() { + local parent name + parent="$(dirname "$ZCODE_HOME")" + name="$(basename "$ZCODE_HOME")" + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + NDDEV_STAGE_PATH="$parent/.${name}.stage.PLAN" + NDDEV_STAGE_IDENTITY="plan" + printf '[DRY-RUN] create same-filesystem staging directory %q (0700)\n' "$NDDEV_STAGE_PATH" + return 0 + fi + NDDEV_STAGE_PATH="$(mktemp -d "$parent/.${name}.stage.XXXXXX")" || return 1 + nddev::assert_direct_child "$parent" "$NDDEV_STAGE_PATH" || return 1 + chmod 700 "$NDDEV_STAGE_PATH" || return 1 + NDDEV_STAGE_IDENTITY="$(nddev::path_identity "$NDDEV_STAGE_PATH" directory)" || return 1 +} + +nddev::find_slot_entry() { + local slot=$1 + python3 -I - "$BACKUPS_DIR" "$slot" "$NDDEV_SEMVER_PATTERN" <<'PY' +import os +import re +import sys + +root, slot, semver = sys.argv[1:] +pattern = re.compile(rf"^([0-9])-(?:unmanaged|{semver})-old\.zcode$") +matches = [] +if os.path.isdir(root): + for entry in os.scandir(root): + match = pattern.fullmatch(entry.name) + if not match: + if re.fullmatch(r"[0-9]-.*-old\.zcode", entry.name): + raise SystemExit("invalid backup slot name") + if entry.name.startswith(".slot-") and ".hold." in entry.name: + raise SystemExit("stale backup recovery hold requires attention") + continue + if entry.is_symlink() or not entry.is_dir(follow_symlinks=False): + raise SystemExit("unsafe backup slot entry") + if match.group(1) == slot: + matches.append(entry.path) +if len(matches) > 1: + raise SystemExit(f"duplicate backup slot: {slot}") +if matches: + print(matches[0]) +PY +} + +# Reserve a backup destination without destroying the current slot occupant. +# The old occupant is held until commit succeeds and restored on failure. +nddev::prepare_backup_destination() { + local version=$1 slot name destination existing hold existing_identity + slot="$(nddev::backup_slot)" || return 1 + name="$(nddev::backup_name "$version" "$slot")" || return 1 + destination="$BACKUPS_DIR/$name" + nddev::assert_direct_child "$BACKUPS_DIR" "$destination" || return 1 + existing="$(nddev::find_slot_entry "$slot")" || return 1 + + NDDEV_BACKUP_PATH="$destination" + if [ -z "$existing" ]; then + return 0 + fi + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + printf '[DRY-RUN] hold occupied backup slot %q until commit\n' "$existing" + return 0 + fi + existing_identity="$(nddev::path_identity "$existing" directory)" || return 1 + hold="$(mktemp -d "$BACKUPS_DIR/.slot-${slot}.hold.XXXXXX")" || return 1 + NDDEV_REPLACED_BACKUP_HOLD="$hold" + NDDEV_REPLACED_BACKUP_HOLD_IDENTITY="$(nddev::path_identity "$hold" directory)" || return 1 + NDDEV_REPLACED_BACKUP_ORIGINAL="$existing" + NDDEV_REPLACED_BACKUP_ORIGINAL_IDENTITY="$existing_identity" + nddev::assert_direct_child "$BACKUPS_DIR" "$hold" || return 1 + chmod 700 "$hold" || return 1 + nddev::rename_noreplace "$existing" "$hold/original" directory "$existing_identity" || return 1 +} + +nddev::write_adoption_envelope() { + local envelope=$1 original_target=$2 build_version created_at + build_version="$(nddev::build_version)" || return 1 + created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + python3 -I - "$envelope/NDDEV-BACKUP.json" "$original_target" "$build_version" "$created_at" <<'PY' +import json +import os +import sys +import tempfile + +path, original_target, build_version, created_at = sys.argv[1:] +payload = { + "schema": 1, + "type": "adopted-unmanaged", + "original_target": original_target, + "created_at": created_at, + "installer_build": build_version, + "payload": "payload", +} +fd, temporary = tempfile.mkstemp(prefix=".nddev-envelope.", dir=os.path.dirname(path), text=True) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(payload, stream, indent=2, ensure_ascii=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) +except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise +PY +} + +# Validate a typed adopted-state envelope and print its contained payload path. +nddev::adoption_payload() { + local envelope=$1 requested_target=$2 allow_relocation=${3:-0} + python3 -I - "$envelope" "$requested_target" "$allow_relocation" "$NDDEV_SEMVER_PATTERN" <<'PY' +import datetime as dt +import json +import os +import re +import sys + +envelope, requested_target, allow_relocation, semver_pattern = sys.argv[1:] +envelope = os.path.realpath(envelope) +requested_target = os.path.realpath(requested_target) +if not re.fullmatch(r"[0-9]-unmanaged-old\.zcode", os.path.basename(envelope)): + raise SystemExit("adopted backup envelope has an invalid slot name") +marker = os.path.join(envelope, "NDDEV-BACKUP.json") +if os.path.islink(marker): + raise SystemExit("adopted backup marker must not be a symlink") +try: + with open(marker, encoding="utf-8") as stream: + data = json.load(stream) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"invalid adopted backup marker: {exc}") +if not isinstance(data, dict) or data.get("schema") != 1 or data.get("type") != "adopted-unmanaged": + raise SystemExit("unsupported adopted backup marker") +if data.get("payload") != "payload": + raise SystemExit("adopted backup payload name must be exactly 'payload'") +original = data.get("original_target") +if ( + not isinstance(original, str) + or any(ord(char) < 32 or ord(char) == 127 for char in original) + or not os.path.isabs(original) + or os.path.realpath(original) != original + or original == "/" +): + raise SystemExit("adopted backup original_target is not canonical") +if allow_relocation != "1" and original != requested_target: + raise SystemExit("adopted backup belongs to a different target; explicit relocation is required") +build = data.get("installer_build") +semver = re.compile(semver_pattern) +if not isinstance(build, str) or not semver.fullmatch(build): + raise SystemExit("adopted backup installer_build is invalid") +created_at = data.get("created_at") +try: + parsed = dt.datetime.fromisoformat(created_at.replace("Z", "+00:00")) +except (AttributeError, ValueError) as exc: + raise SystemExit("adopted backup created_at is invalid") from exc +if parsed.tzinfo is None or parsed.utcoffset() != dt.timedelta(0): + raise SystemExit("adopted backup created_at must be UTC") +payload = os.path.realpath(os.path.join(envelope, "payload")) +if os.path.dirname(payload) != envelope or not os.path.isdir(payload) or os.path.islink(payload): + raise SystemExit("adopted backup payload escapes its envelope or is unsafe") +print(payload) +PY +} + +nddev::commit_stage() { + local current_version=$1 had_target=$2 + nddev::section "Commit transaction" + if [ "$had_target" -eq 1 ]; then + if ! nddev::identity_matches "$ZCODE_HOME" directory "$NDDEV_ORIGINAL_TARGET_IDENTITY"; then + nddev::log "error" "install target identity changed before commit" + return 1 + fi + nddev::prepare_backup_destination "$current_version" || return 1 + nddev::log "info" "backup target: $NDDEV_BACKUP_PATH" + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + if [ "$current_version" = "unmanaged" ]; then + printf '[DRY-RUN] create typed adopted-unmanaged envelope %q and move target into payload/\n' "$NDDEV_BACKUP_PATH" + else + printf '[DRY-RUN] exclusive atomic rename %q %q\n' "$ZCODE_HOME" "$NDDEV_BACKUP_PATH" + fi + else + if [ "$current_version" = "unmanaged" ]; then + NDDEV_ROLLBACK_ENVELOPE="$NDDEV_BACKUP_PATH" + mkdir -m 700 "$NDDEV_BACKUP_PATH" || return + NDDEV_ROLLBACK_ENVELOPE_IDENTITY="$(nddev::path_identity "$NDDEV_BACKUP_PATH" directory)" || return 1 + NDDEV_ROLLBACK_BACKUP="$NDDEV_BACKUP_PATH/payload" + NDDEV_ROLLBACK_IDENTITY="$NDDEV_ORIGINAL_TARGET_IDENTITY" + nddev::rename_noreplace \ + "$ZCODE_HOME" "$NDDEV_BACKUP_PATH/payload" directory "$NDDEV_ROLLBACK_IDENTITY" || return + nddev::write_adoption_envelope "$NDDEV_BACKUP_PATH" "$ZCODE_HOME" || return + else + NDDEV_ROLLBACK_BACKUP="$NDDEV_BACKUP_PATH" + NDDEV_ROLLBACK_IDENTITY="$NDDEV_ORIGINAL_TARGET_IDENTITY" + nddev::rename_noreplace \ + "$ZCODE_HOME" "$NDDEV_BACKUP_PATH" directory "$NDDEV_ROLLBACK_IDENTITY" || return + fi + if ! nddev::identity_matches "$NDDEV_ROLLBACK_BACKUP" directory "$NDDEV_ROLLBACK_IDENTITY"; then + nddev::log "error" "rollback source identity changed after backup move" + return 1 + fi + nddev::normalize_tree_permissions "$NDDEV_BACKUP_PATH" || return + touch "$NDDEV_BACKUP_PATH" || return + nddev::sync_directory "$NDDEV_BACKUP_PATH" || return + nddev::sync_directory "$BACKUPS_DIR" || return + fi + else + NDDEV_BACKUP_PATH="" + fi + + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + printf '[DRY-RUN] atomic rename %q %q\n' "$NDDEV_STAGE_PATH" "$ZCODE_HOME" + else + if ! nddev::identity_matches "$NDDEV_STAGE_PATH" directory "$NDDEV_STAGE_IDENTITY"; then + nddev::log "error" "staging directory identity changed before commit" + return 1 + fi + NDDEV_LIVE_SOURCE_ID="$NDDEV_STAGE_IDENTITY" + NDDEV_LIVE_RENAME_PENDING=1 + if ! nddev::rename_noreplace \ + "$NDDEV_STAGE_PATH" "$ZCODE_HOME" directory "$NDDEV_LIVE_SOURCE_ID"; then + nddev::reconcile_live_rename || return 1 + return 1 + fi + NDDEV_LIVE_SWAPPED=1 + NDDEV_LIVE_RENAME_PENDING=0 + NDDEV_LIVE_IDENTITY="$NDDEV_STAGE_IDENTITY" + NDDEV_STAGE_PATH="" + nddev::sync_directory "$(dirname "$ZCODE_HOME")" || return + fi +} + +# --- Build ----------------------------------------------------------------- -# Create the empty runtime directories ZCode expects to find under ~/.zcode. nddev::create_runtime_dirs() { - local target=$1 + local target=$1 directory local dirs=( "$target/cli/agents" "$target/cli/artifacts" @@ -115,256 +861,563 @@ nddev::create_runtime_dirs() { "$target/v2/logs" "$target/v2/crash" ) - local d - for d in "${dirs[@]}"; do - nddev::ensure_dir "$d" + for directory in "${dirs[@]}"; do + nddev::ensure_dir "$directory" || return 1 done } -# Merge the seven hook events from hooks.json into the rendered cli/config.json. -# Config-file hooks shape: events live under hooks.events. (NOT hooks.). -# hooks.json shape (after _comment stripped): { "SessionStart": [...], ... }. -# Uses python3 for safe JSON manipulation. $1=cli/config.json path, $2=hooks.json path. nddev::merge_hooks() { local config_path=$1 hooks_path=$2 if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then printf '[DRY-RUN] merge hooks %q -> %q\n' "$hooks_path" "$config_path" return 0 fi - python3 - "$config_path" "$hooks_path" <<'PY' -import json, sys + python3 -I - "$config_path" "$hooks_path" <<'PY' +import json +import os +import sys +import tempfile -config_path, hooks_path = sys.argv[1], sys.argv[2] -with open(config_path) as f: - config = json.load(f) -with open(hooks_path) as f: - hooks_src = json.load(f) -hooks_src.pop("_comment", None) - -# Config-file hooks go under hooks.events. (per the official -# diagnosing-hooks skill: the configuration file uses hooks.events.). -config.setdefault("hooks", {}) +config_path, hooks_path = sys.argv[1:] +with open(config_path, encoding="utf-8") as stream: + config = json.load(stream) +with open(hooks_path, encoding="utf-8") as stream: + hooks = json.load(stream) +if not isinstance(config, dict) or not isinstance(hooks, dict): + raise SystemExit("hook merge inputs must be JSON objects") +hooks.pop("_comment", None) +events = config.setdefault("hooks", {}).setdefault("events", {}) config["hooks"].setdefault("enabled", True) -config["hooks"].setdefault("events", {}) -for event, entries in hooks_src.items(): +for event, entries in hooks.items(): if not isinstance(entries, list): - continue - config["hooks"]["events"].setdefault(event, []) - config["hooks"]["events"][event].extend(entries) - -with open(config_path, "w") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - f.write("\n") + raise SystemExit(f"hook event must contain a list: {event}") + existing = events.setdefault(event, []) + if not isinstance(existing, list): + raise SystemExit(f"configured hook event is not a list: {event}") + existing.extend(entries) +fd, temporary = tempfile.mkstemp(prefix=".nddev-hooks.", dir=os.path.dirname(config_path), text=True) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(config, stream, indent=2, ensure_ascii=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, config_path) +except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise PY } -# Merge MCP servers from mcp.json into the rendered cli/config.json under mcp.servers. -# mcp.json shape (after _comment stripped): { "mcpServers": { "": {...} } }. -# $1=cli/config.json path, $2=mcp.json path. nddev::merge_mcp() { local config_path=$1 mcp_path=$2 if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] merge mcp %q -> %q\n' "$mcp_path" "$config_path" + printf '[DRY-RUN] merge rendered MCP %q -> %q\n' "$mcp_path" "$config_path" return 0 fi - python3 - "$config_path" "$mcp_path" <<'PY' -import json, sys + python3 -I - "$config_path" "$mcp_path" <<'PY' +import json +import os +import sys +import tempfile + +config_path, mcp_path = sys.argv[1:] +with open(config_path, encoding="utf-8") as stream: + config = json.load(stream) +with open(mcp_path, encoding="utf-8") as stream: + mcp = json.load(stream) +if not isinstance(config, dict) or not isinstance(mcp, dict): + raise SystemExit("MCP merge inputs must be JSON objects") +servers = mcp.get("mcpServers", {}) +if not isinstance(servers, dict): + raise SystemExit("mcpServers must be a JSON object") +configured = config.setdefault("mcp", {}).setdefault("servers", {}) +if not isinstance(configured, dict): + raise SystemExit("mcp.servers must be a JSON object") +configured.update(servers) +fd, temporary = tempfile.mkstemp(prefix=".nddev-mcp.", dir=os.path.dirname(config_path), text=True) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(config, stream, indent=2, ensure_ascii=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, config_path) +except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise +PY +} + +nddev::validate_plan_configs() { + python3 -I - "$SOURCE_DIR" <<'PY' || return 1 +import json +import os +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +placeholder = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + +def load(name, *, optional=False): + path = root / name + if optional and not path.exists(): + return None + if path.is_symlink() or not path.is_file(): + raise SystemExit(f"missing safe JSON plan input: {name}") + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + if not isinstance(value, dict): + raise SystemExit(f"JSON plan input must contain an object: {name}") + value.pop("_comment", None) + return value + +def substitute(value): + if isinstance(value, str): + def replace(match): + replacement = os.environ.get(match.group(1)) + return replacement if replacement not in (None, "") else match.group(0) + return placeholder.sub(replace, value) + if isinstance(value, list): + return [substitute(item) for item in value] + if isinstance(value, dict): + return {key: substitute(item) for key, item in value.items()} + return value + +def child_path(path, key): + return f"{path}[{key!r}]" + +def unresolved_keys(value, path): + failures = [] + if isinstance(value, list): + for index, item in enumerate(value): + failures.extend(unresolved_keys(item, f"{path}[{index}]")) + elif isinstance(value, dict): + for key, item in value.items(): + location = child_path(path, key) + if isinstance(key, str) and placeholder.search(key): + failures.append(f"{location}.") + failures.extend(unresolved_keys(item, location)) + return failures + +def unresolved_values(value, path): + failures = [] + if isinstance(value, str) and placeholder.search(value): + failures.append(path) + elif isinstance(value, list): + for index, item in enumerate(value): + failures.extend(unresolved_values(item, f"{path}[{index}]")) + elif isinstance(value, dict): + for key, item in value.items(): + failures.extend(unresolved_values(item, child_path(path, key))) + return failures + +cli = substitute(load("cli-config.template.json")) +providers = substitute(load("v2-config.template.json")) +settings = substitute(load("v2-setting.template.json")) + +hooks = load("hooks.json", optional=True) +if hooks is not None: + hooks = substitute(hooks) + events = cli.setdefault("hooks", {}).setdefault("events", {}) + if not isinstance(events, dict): + raise SystemExit("hooks.events must be a JSON object") + cli["hooks"].setdefault("enabled", True) + for event, entries in hooks.items(): + if not isinstance(entries, list): + raise SystemExit(f"hook event must contain a list: {event}") + existing = events.setdefault(event, []) + if not isinstance(existing, list): + raise SystemExit(f"configured hook event is not a list: {event}") + existing.extend(entries) -config_path, mcp_path = sys.argv[1], sys.argv[2] -with open(config_path) as f: - config = json.load(f) -with open(mcp_path) as f: - mcp_src = json.load(f) -mcp_src.pop("_comment", None) +mcp = load("mcp.json", optional=True) +if mcp is not None: + mcp = substitute(mcp) + servers = mcp.get("mcpServers", {}) + if not isinstance(servers, dict): + raise SystemExit("mcpServers must be a JSON object") + configured = cli.setdefault("mcp", {}).setdefault("servers", {}) + if not isinstance(configured, dict): + raise SystemExit("mcp.servers must be a JSON object") + configured.update(servers) -servers = mcp_src.get("mcpServers", {}) -config.setdefault("mcp", {}) -config["mcp"].setdefault("servers", {}) -config["mcp"]["servers"].update(servers) +failures = [] +for root_name, value in ( + ("provider-config", providers), + ("setting", settings), + ("cli", cli), +): + failures.extend(unresolved_keys(value, root_name)) -with open(config_path, "w") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - f.write("\n") +for key, value in providers.items(): + if key != "provider": + failures.extend(unresolved_values(value, child_path("provider-config", key))) + elif isinstance(value, dict): + for name, provider in value.items(): + if isinstance(provider, dict) and provider.get("enabled") is False: + continue + failures.extend(unresolved_values(provider, child_path("provider", name))) + else: + failures.extend(unresolved_values(value, child_path("provider-config", key))) + +failures.extend(unresolved_values(settings, "setting")) + +for key, value in cli.items(): + if key != "mcp": + failures.extend(unresolved_values(value, child_path("cli", key))) + elif isinstance(value, dict): + for mcp_key, mcp_value in value.items(): + if mcp_key != "servers": + failures.extend(unresolved_values(mcp_value, child_path("mcp", mcp_key))) + elif isinstance(mcp_value, dict): + for name, server in mcp_value.items(): + if isinstance(server, dict) and server.get("enabled") is False: + continue + failures.extend(unresolved_values(server, child_path("mcp.servers", name))) + else: + failures.extend(unresolved_values(mcp_value, child_path("mcp", mcp_key))) + else: + failures.extend(unresolved_values(value, child_path("cli", key))) + +if failures: + raise SystemExit("unresolved required placeholder(s): " + ", ".join(failures)) PY + nddev::log "ok" "plan config render, merge, and active-placeholder contracts passed" } -# Render the templated config files (secrets from build/.env, ${HOME} expanded), -# then merge hooks.json and mcp.json into cli/config.json. nddev::render_configs() { - local target=$1 + local target=$1 rendered_mcp rendered_hooks nddev::section "Render config templates" + nddev::render_template "$SOURCE_DIR/cli-config.template.json" "$target/cli/config.json" || return 1 - # cli/config.json — plugins, hooks, mcp servers. - nddev::render_template "$SOURCE_DIR/cli-config.template.json" "$target/cli/config.json" - - # Merge hooks.json (per-event arrays) into cli/config.json.hooks. if [ -f "$SOURCE_DIR/hooks.json" ]; then - nddev::merge_hooks "$target/cli/config.json" "$SOURCE_DIR/hooks.json" + rendered_hooks="$target/cli/.hooks.rendered.json" + nddev::render_template "$SOURCE_DIR/hooks.json" "$rendered_hooks" || return 1 + nddev::merge_hooks "$target/cli/config.json" "$rendered_hooks" || return 1 + [ "${NDDEV_DRY_RUN:-1}" -eq 1 ] || rm -f "$rendered_hooks" || return 1 fi - - # Merge mcp.json (mcpServers) into cli/config.json.mcp.servers. if [ -f "$SOURCE_DIR/mcp.json" ]; then - nddev::merge_mcp "$target/cli/config.json" "$SOURCE_DIR/mcp.json" + rendered_mcp="$target/cli/.mcp.rendered.json" + nddev::render_template "$SOURCE_DIR/mcp.json" "$rendered_mcp" || return 1 + nddev::merge_mcp "$target/cli/config.json" "$rendered_mcp" || return 1 + [ "${NDDEV_DRY_RUN:-1}" -eq 1 ] || rm -f "$rendered_mcp" || return 1 fi - # v2/config.json — provider definitions with ${API_KEY} placeholders. - nddev::render_template "$SOURCE_DIR/v2-config.template.json" "$target/v2/config.json" - - # v2/setting.json — preferences with ${HOME} expanded. - nddev::render_template "$SOURCE_DIR/v2-setting.template.json" "$target/v2/setting.json" + nddev::render_template "$SOURCE_DIR/v2-config.template.json" "$target/v2/config.json" || return 1 + nddev::render_template "$SOURCE_DIR/v2-setting.template.json" "$target/v2/setting.json" || return 1 + if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + nddev::validate_required_placeholders \ + "$target/cli/config.json" "$target/v2/config.json" "$target/v2/setting.json" || return 1 + else + nddev::validate_plan_configs || return 1 + fi } -# Render ~/.zcode/.env from build/.env (for CLI tools that read secrets at runtime). -# This file is gitignored and contains real secret values — it lets CLI tool -# scripts source secrets without needing system-level env setup. nddev::render_env() { - local target=$1 - local src_env + local target=$1 src_env src_env="$(nddev::repo_root)/build/.env" - if [ ! -f "$src_env" ]; then - nddev::log "info" "no build/.env — skipping ~/.zcode/.env (CLI tools will need env vars set externally)" + if [ ! -e "$src_env" ] && [ ! -L "$src_env" ]; then + if [ "${NDDEV_ENV_DIGEST:-}" != "absent" ]; then + nddev::log "error" "build/.env disappeared after environment loading" + return 1 + fi + nddev::log "info" "no build/.env — runtime tools must receive secrets from the environment" return 0 fi + if [ -z "${NDDEV_ENV_DIGEST:-}" ] || [ "$NDDEV_ENV_DIGEST" = "absent" ]; then + nddev::log "error" "build/.env appeared after environment loading" + return 1 + fi if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] cp %q -> %q\n' "$src_env" "$target/.env" + nddev::parse_env_file "$src_env" "$NDDEV_ENV_DIGEST" >/dev/null || return 1 + printf '[DRY-RUN] install private env %q -> %q (0600)\n' "$src_env" "$target/.env" return 0 fi - cp "$src_env" "$target/.env" - chmod 600 "$target/.env" - nddev::log "ok" "wrote $target/.env (600) for CLI tool secrets" + nddev::parse_env_file "$src_env" "$NDDEV_ENV_DIGEST" "$target/.env" >/dev/null || return 1 } -# Copy the static source tree from the selected marketplace into ~/.zcode. -# The marketplace IS the setup: AGENTS.md, config files, skills/commands/agents, -# and its own plugins (installed as a marketplace ZCode can discover). nddev::copy_source_tree() { - local target=$1 - local mp_name + local target=$1 mp_name directory mp_name="$(basename "$SOURCE_DIR")" nddev::section "Copy source tree (marketplace: $mp_name)" - - # AGENTS.md -> ~/.zcode/AGENTS.md (the system instruction file) - nddev::copy "$SOURCE_DIR/AGENTS.md" "$target/AGENTS.md" - - # The marketplace directory itself -> ~/.zcode/marketplaces// - # (so ZCode's Plugin Management can discover it by local directory). - nddev::ensure_dir "$target/marketplaces" - nddev::copy "$SOURCE_DIR" "$target/marketplaces/$mp_name" - - # User-scope skills/, commands/, agents/ -> ~/.zcode/{skills,commands,agents}/ - # (copied OUT of the marketplace into the top-level user-scope dirs ZCode scans). - local d - for d in skills commands agents; do - if [ -d "$SOURCE_DIR/$d" ]; then - nddev::copy "$SOURCE_DIR/$d/." "$target/$d/" + nddev::copy "$SOURCE_DIR/AGENTS.md" "$target/AGENTS.md" || return 1 + nddev::ensure_dir "$target/marketplaces" || return 1 + nddev::copy "$SOURCE_DIR" "$target/marketplaces/$mp_name" || return 1 + for directory in skills commands agents; do + if [ -d "$SOURCE_DIR/$directory" ]; then + nddev::ensure_dir "$target/$directory" || return 1 + nddev::copy "$SOURCE_DIR/$directory/." "$target/$directory/" || return 1 fi done } -# Build a clean ~/.zcode from the selected marketplace source. nddev::build_clean() { local target=$1 - nddev::section "Build clean ~/.zcode" - nddev::ensure_dir "$target" - nddev::ensure_dir "$target/cli" - nddev::ensure_dir "$target/v2" - nddev::create_runtime_dirs "$target" - nddev::copy_source_tree "$target" - nddev::render_configs "$target" - nddev::render_env "$target" + nddev::section "Build isolated staging tree" + nddev::ensure_dir "$target" || return 1 + nddev::ensure_dir "$target/cli" || return 1 + nddev::ensure_dir "$target/v2" || return 1 + nddev::create_runtime_dirs "$target" || return 1 + nddev::copy_source_tree "$target" || return 1 + nddev::render_configs "$target" || return 1 + nddev::render_env "$target" || return 1 } -# --- Restore (selective, from backup) ------------------------------------ - -# Restore runtime state from the most recent backup. Delegates to restore.sh. nddev::restore_runtime() { - local backup=$1 - local target=$2 - if [ ! -d "$backup" ]; then - nddev::log "info" "no backup to restore from (fresh install)" + local source=$1 target=$2 managed=${3:-managed} + if [ ! -d "$source" ]; then + nddev::log "info" "no existing state to adopt (fresh install)" return 0 fi - nddev::section "Restore runtime state from backup" - # restore.sh is self-contained and reads NDDEV_DRY_RUN itself. - NDDEV_BACKUP="$backup" NDDEV_TARGET="$target" "$RESTORE_SCRIPT" + nddev::section "Restore selected runtime state into staging" + if [ "$managed" = "unmanaged" ]; then + NDDEV_ALLOW_UNMANAGED_BACKUP=1 NDDEV_BACKUP="$source" NDDEV_TARGET="$target" "$RESTORE_SCRIPT" + else + NDDEV_ALLOW_UNMANAGED_BACKUP=0 NDDEV_BACKUP="$source" NDDEV_TARGET="$target" "$RESTORE_SCRIPT" + fi } -# --- Verify -------------------------------------------------------------- +# --- Verification ---------------------------------------------------------- -# Validate the freshly built ~/.zcode. -nddev::verify_build() { - local target=$1 - local mp_name - mp_name="$(basename "$SOURCE_DIR")" - nddev::section "Verify build" - local errors=0 - - if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then - if [ ! -f "$target/AGENTS.md" ]; then - nddev::log "missing" "AGENTS.md not found" - errors=$((errors + 1)) - fi - if ! nddev::validate_json "$target/cli/config.json" 2>/dev/null; then - nddev::log "missing" "cli/config.json is not valid JSON" - errors=$((errors + 1)) - fi - if ! nddev::validate_json "$target/v2/config.json" 2>/dev/null; then - nddev::log "missing" "v2/config.json is not valid JSON" - errors=$((errors + 1)) - fi - if ! nddev::validate_json "$target/v2/setting.json" 2>/dev/null; then - nddev::log "missing" "v2/setting.json is not valid JSON" - errors=$((errors + 1)) - fi - # Validate the selected marketplace manifest. - local mp_json="$target/marketplaces/$mp_name/marketplace.json" - if [ ! -f "$mp_json" ]; then - nddev::log "missing" "$mp_name/marketplace.json not installed" - errors=$((errors + 1)) - elif ! nddev::validate_json "$mp_json" 2>/dev/null; then - nddev::log "missing" "$mp_name/marketplace.json is not valid JSON" +nddev::verify_managed_tree() { + local target=$1 errors=0 + if [ ! -d "$target" ] || [ -L "$target" ]; then + nddev::log "missing" "managed tree is not a real directory: $target" + return 1 + fi + nddev::assert_safe_tree "$target" || errors=$((errors + 1)) + nddev::stamp_version "$target" >/dev/null || { + nddev::log "missing" "BUILD-VERSION does not satisfy the managed schema" + errors=$((errors + 1)) + } + local path + for path in cli/config.json v2/config.json v2/setting.json; do + if [ ! -f "$target/$path" ] || ! nddev::validate_json "$target/$path" >/dev/null 2>&1; then + nddev::log "missing" "$path is missing or invalid" errors=$((errors + 1)) fi + done + if [ "$errors" -eq 0 ]; then + nddev::validate_required_placeholders \ + "$target/cli/config.json" "$target/v2/config.json" "$target/v2/setting.json" \ + || errors=$((errors + 1)) fi + [ "$errors" -eq 0 ] +} +nddev::verify_build() { + local target=$1 mp_name errors=0 + mp_name="$(basename "$SOURCE_DIR")" + nddev::section "Verify staged build" + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + nddev::log "ok" "all checks passed (planned staged verification)" + return 0 + fi + nddev::verify_managed_tree "$target" || errors=$((errors + 1)) + if [ ! -f "$target/AGENTS.md" ]; then + nddev::log "missing" "AGENTS.md not found" + errors=$((errors + 1)) + fi + local manifest="$target/marketplaces/$mp_name/marketplace.json" + if [ ! -f "$manifest" ] || ! nddev::validate_json "$manifest" >/dev/null 2>&1; then + nddev::log "missing" "$mp_name/marketplace.json is missing or invalid" + errors=$((errors + 1)) + fi + nddev::normalize_tree_permissions "$target" || errors=$((errors + 1)) + python3 -I - "$target" <<'PY' || errors=$((errors + 1)) +import os +import stat +import sys + +root = sys.argv[1] +if stat.S_IMODE(os.stat(root).st_mode) != 0o700: + raise SystemExit("target root must be 0700") +for relative in ("BUILD-VERSION", "cli/config.json", "v2/config.json"): + path = os.path.join(root, relative) + if stat.S_IMODE(os.stat(path).st_mode) != 0o600: + raise SystemExit(f"sensitive config must be 0600: {relative}") +PY + nddev::sync_tree "$target" || errors=$((errors + 1)) if [ "$errors" -gt 0 ]; then - nddev::log "error" "$errors verification error(s)" + nddev::log "error" "$errors staged verification error(s)" return 1 fi - nddev::log "ok" "all checks passed" + nddev::log "ok" "staged build is complete, private, and internally consistent" } -# --- Orchestration ------------------------------------------------------- +# --- Public orchestration -------------------------------------------------- -# Full install sequence. Called by the platform runners after they select a -# marketplace. Sets NDDEV_BACKUP_PATH to the backup directory (empty on a fresh -# install with no prior ~/.zcode). -NDDEV_BACKUP_PATH="" nddev::install_sequence() { - local platform=$1 - local current_version backup_name backup_path + local platform=$1 current_version had_target=0 adoption_mode=managed + nddev::prepare_paths || return + nddev::log "info" "target: $ZCODE_HOME" + nddev::begin_transaction || return + if [ -e "$ZCODE_HOME" ]; then + had_target=1 + current_version="$(nddev::current_version)" || return 1 + NDDEV_ORIGINAL_TARGET_IDENTITY="$(nddev::path_identity "$ZCODE_HOME" directory)" || return 1 + if [ "$current_version" = "unmanaged" ]; then + if [ "${NDDEV_ADOPT_UNMANAGED:-0}" != "1" ]; then + nddev::log "error" "refusing to replace an unstamped target; use --adopt-unmanaged with an explicit --target" + return 1 + fi + adoption_mode=unmanaged + elif [ "${NDDEV_ADOPT_UNMANAGED:-0}" = "1" ]; then + nddev::log "error" "--adopt-unmanaged is only valid for an existing unstamped target" + return 2 + fi + else + current_version="unmanaged" + if [ "${NDDEV_ADOPT_UNMANAGED:-0}" = "1" ]; then + nddev::log "error" "--adopt-unmanaged requires an existing unstamped target" + return 2 + fi + fi - current_version="$(nddev::current_version)" + nddev::create_stage || return + nddev::check_runtime_version || return + nddev::build_clean "$NDDEV_STAGE_PATH" || return + nddev::write_version_stamp "$NDDEV_STAGE_PATH" "$platform" || return + if [ "$had_target" -eq 1 ]; then + nddev::restore_runtime "$ZCODE_HOME" "$NDDEV_STAGE_PATH" "$adoption_mode" || return + fi + nddev::verify_build "$NDDEV_STAGE_PATH" || return + nddev::commit_stage "$current_version" "$had_target" || return + nddev::finish_transaction || return +} - # Compute the backup path BEFORE backing up (so we can restore from it). - if [ -d "$ZCODE_HOME" ]; then - backup_name="$(nddev::backup_name "$current_version")" - backup_path="$BACKUPS_DIR/$backup_name" +# Move a strictly managed target into a contained backup slot. This is the +# complete remove operation; no follow-up rm -rf is required. +nddev::remove_managed_target() { + local current_version + nddev::prepare_paths || return + nddev::log "info" "target: $ZCODE_HOME" + nddev::begin_transaction || return + if [ ! -e "$ZCODE_HOME" ]; then + nddev::log "info" "nothing to remove: $ZCODE_HOME does not exist" + nddev::finish_transaction || return + return 0 + fi + current_version="$(nddev::stamp_version "$ZCODE_HOME")" || { + nddev::log "error" "refusing to remove: target has no valid managed BUILD-VERSION" + return 1 + } + nddev::assert_safe_tree "$ZCODE_HOME" || { + nddev::log "error" "refusing to remove a managed tree containing unsafe filesystem entries" + return 1 + } + NDDEV_ORIGINAL_TARGET_IDENTITY="$(nddev::path_identity "$ZCODE_HOME" directory)" || return 1 + nddev::prepare_backup_destination "$current_version" || return + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + printf '[DRY-RUN] atomic move %q %q\n' "$ZCODE_HOME" "$NDDEV_BACKUP_PATH" else - backup_path="" + NDDEV_ROLLBACK_BACKUP="$NDDEV_BACKUP_PATH" + NDDEV_ROLLBACK_IDENTITY="$NDDEV_ORIGINAL_TARGET_IDENTITY" + nddev::rename_noreplace \ + "$ZCODE_HOME" "$NDDEV_BACKUP_PATH" directory "$NDDEV_ROLLBACK_IDENTITY" || return + if ! nddev::identity_matches "$NDDEV_ROLLBACK_BACKUP" directory "$NDDEV_ROLLBACK_IDENTITY"; then + nddev::log "error" "backup identity changed after remove move" + return 1 + fi + nddev::normalize_tree_permissions "$NDDEV_BACKUP_PATH" || return + touch "$NDDEV_BACKUP_PATH" || return + nddev::sync_directory "$NDDEV_BACKUP_PATH" || return + nddev::sync_directory "$BACKUPS_DIR" || return fi + nddev::finish_transaction || return +} - nddev::backup_current - nddev::check_runtime_version - nddev::build_clean "$ZCODE_HOME" - nddev::write_version_stamp "$ZCODE_HOME" "$platform" - - if [ -n "$backup_path" ]; then - nddev::restore_runtime "$backup_path" "$ZCODE_HOME" +# Resolve and restore a slot while holding both target and backup-pool locks. +nddev::restore_backup_slot() { + local slot=$1 source current_version="" had_target=0 restore_kind="managed" payload + nddev::prepare_paths || return + nddev::begin_transaction || return + source="$(nddev::find_slot_entry "$slot")" || return 1 + if [ -z "$source" ] || [ ! -d "$source" ] || [ -L "$source" ]; then + nddev::log "error" "no safe backup found in slot $slot" + return 1 + fi + source="$(nddev::canonical_path "$source")" || return 2 + nddev::assert_direct_child "$BACKUPS_DIR" "$source" || return 1 + if [ ! -d "$source" ] || [ -L "$source" ]; then + nddev::log "error" "restore source must be a real backup directory: $source" + return 1 + fi + nddev::assert_safe_tree "$source" || return 1 + if nddev::stamp_version "$source" >/dev/null 2>&1; then + if [ "${NDDEV_ALLOW_TARGET_RELOCATION:-0}" = "1" ]; then + nddev::log "error" "--allow-target-relocation applies only to adopted-unmanaged envelopes" + return 2 + fi + payload="$source" + else + restore_kind="adopted-unmanaged" + payload="$(nddev::adoption_payload "$source" "$ZCODE_HOME" "${NDDEV_ALLOW_TARGET_RELOCATION:-0}")" || { + nddev::log "error" "restore source is neither a managed backup nor a valid adopted-state envelope" + return 1 + } + nddev::assert_safe_tree "$payload" || return 1 + if [ -e "$payload/BUILD-VERSION" ] || [ -L "$payload/BUILD-VERSION" ]; then + nddev::log "error" "adopted-state payload must remain unstamped" + return 1 + fi + fi + if [ "$restore_kind" = "managed" ]; then + nddev::verify_managed_tree "$payload" || { + nddev::log "error" "managed backup does not satisfy the full restore contract" + return 1 + } + fi + if [ -e "$ZCODE_HOME" ]; then + had_target=1 + current_version="$(nddev::stamp_version "$ZCODE_HOME")" || { + nddev::log "error" "refusing to restore: target has no valid managed BUILD-VERSION" + return 1 + } + nddev::assert_safe_tree "$ZCODE_HOME" || { + nddev::log "error" "refusing to replace a managed target containing unsafe filesystem entries" + return 1 + } + NDDEV_ORIGINAL_TARGET_IDENTITY="$(nddev::path_identity "$ZCODE_HOME" directory)" || return 1 fi - nddev::verify_build "$ZCODE_HOME" + # Read by install.sh after this sourced function returns. + # shellcheck disable=SC2034 + NDDEV_RESTORE_SOURCE="$source" + nddev::section "Restore from backup slot $slot" + nddev::log "info" "backup: $source" + nddev::log "info" "target: $ZCODE_HOME" + nddev::log "info" "mode: $([ "${NDDEV_DRY_RUN:-1}" -eq 0 ] && echo APPLY || echo 'PLAN (dry-run)')" - # shellcheck disable=SC2034 # read by the sourced platform runner. - NDDEV_BACKUP_PATH="$backup_path" + nddev::create_stage || return + if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then + printf '[DRY-RUN] copy %s payload %q -> %q\n' "$restore_kind" "$payload" "$NDDEV_STAGE_PATH" + if [ "$restore_kind" = "managed" ]; then + printf '[DRY-RUN] validate managed restore staging tree and BUILD-VERSION\n' + else + printf '[DRY-RUN] validate adopted-unmanaged payload remains unstamped\n' + fi + else + cp -R "$payload/." "$NDDEV_STAGE_PATH/" || return + nddev::normalize_tree_permissions "$NDDEV_STAGE_PATH" || return + nddev::sync_tree "$NDDEV_STAGE_PATH" || return + if [ "$restore_kind" = "managed" ]; then + nddev::verify_managed_tree "$NDDEV_STAGE_PATH" || return + elif [ -e "$NDDEV_STAGE_PATH/BUILD-VERSION" ]; then + nddev::log "error" "adopted-state payload unexpectedly contains BUILD-VERSION" + return 1 + fi + fi + nddev::commit_stage "${current_version:-unmanaged}" "$had_target" || return + nddev::finish_transaction || return } diff --git a/cli-tools/scripts/lib/common.sh b/cli-tools/scripts/lib/common.sh index d2c9aa0..09276b1 100644 --- a/cli-tools/scripts/lib/common.sh +++ b/cli-tools/scripts/lib/common.sh @@ -1,76 +1,314 @@ #!/usr/bin/env bash # Shared helpers for the nddev-zcode-app installer. -# Sourced by install.sh and the macos/ubuntu runners. # Callers enable strict shell mode. NDDEV_DRY_RUN=1 means plan (no writes), and # NDDEV_DRY_RUN=0 means apply. -# Resolve the repository root (three levels up from this file: -# lib/ -> scripts/ -> cli-tools/ -> repo root). +# Runtime configuration and restored credentials are private by default. Each +# copied executable keeps its source mode, while newly created files cannot be +# group- or world-readable. +umask 077 + +# Single Python-compatible SemVer 2.0.0 grammar for every version consumer. +readonly NDDEV_SEMVER_PATTERN='(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?' + nddev::repo_root() { local script_dir script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" printf '%s\n' "$(cd "$script_dir/../../.." && pwd)" } -# Load build/.env if present. Exports every KEY=VALUE so the installer (target -# dir, backup dir) and the template renderer (secrets) both see them. -# Comments and blank lines are skipped; values may be quoted. Does NOT override -# a variable already set in the environment (env wins over .env). +# Load build/.env without evaluating shell syntax. Existing environment values +# win. `paths-only` imports only the two documented path keys. `full` also +# imports variables referenced by the already validated marketplace JSON. +nddev::parse_env_file() { + local path=$1 expected_digest=${2:-} snapshot_dest=${3:-} + python3 -I - "$path" "$expected_digest" "$snapshot_dest" <<'PY' +import hashlib +import os +import re +import stat +import sys +import tempfile + +path, expected_digest, snapshot_dest = sys.argv[1:] +try: + before = os.lstat(path) +except OSError as exc: + raise SystemExit("build/.env cannot be inspected") from exc +if not stat.S_ISREG(before.st_mode): + raise SystemExit("build/.env must be a regular non-symlink file") +if before.st_uid != os.getuid(): + raise SystemExit("build/.env must be owned by the current user") +if stat.S_IMODE(before.st_mode) & 0o077: + raise SystemExit("build/.env must not grant group or world permissions") + +flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) +try: + descriptor = os.open(path, flags) +except OSError as exc: + raise SystemExit("build/.env cannot be opened safely") from exc +try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(opened.st_mode) + or opened.st_uid != os.getuid() + or stat.S_IMODE(opened.st_mode) & 0o077 + or (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino) + ): + raise SystemExit("build/.env changed during validation") + chunks = [] + total = 0 + while True: + chunk = os.read(descriptor, 65536) + if not chunk: + break + total += len(chunk) + if total > 1024 * 1024: + raise SystemExit("build/.env exceeds the 1 MiB safety limit") + chunks.append(chunk) +finally: + os.close(descriptor) +raw = b"".join(chunks) +digest = hashlib.sha256(raw).hexdigest() +if expected_digest and digest != expected_digest: + raise SystemExit("build/.env changed after it was loaded") +try: + text = raw.decode("utf-8") +except UnicodeDecodeError as exc: + raise SystemExit("build/.env must be valid UTF-8") from exc +if any(ord(char) < 32 and char != "\n" or ord(char) == 127 for char in text): + raise SystemExit("build/.env contains unsupported control characters") + +assignment = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=(.*)") +reserved_exact = { + "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "BASH_ENV", "ENV", "IFS", + "CDPATH", "GLOBIGNORE", "SHELLOPTS", "BASHOPTS", "PS4", "PROMPT_COMMAND", + "NODE_OPTIONS", "RUBYOPT", "RUBYLIB", "PERL5OPT", "PERL5LIB", "ZDOTDIR", + "GIT_ASKPASS", "SSH_ASKPASS", +} +reserved_prefixes = ( + "XDG_", "GIT_CONFIG_", "PYTHON", "NODE_", "LD_", "DYLD_", "NDDEV_", +) + +def fail(message, line_number): + raise SystemExit(f"{message} at line {line_number}") + +def decode_value(value, line_number): + if not value: + return "" + if value.startswith("'"): + if len(value) < 2 or not value.endswith("'") or "'" in value[1:-1]: + fail("build/.env has malformed single-quoted value", line_number) + decoded = value[1:-1] + elif value.startswith('"'): + if len(value) < 2 or not value.endswith('"'): + fail("build/.env has malformed double-quoted value", line_number) + content = value[1:-1] + decoded_chars = [] + index = 0 + while index < len(content): + char = content[index] + if char == '"': + fail("build/.env has unescaped quote in value", line_number) + if char == "\\": + index += 1 + if index >= len(content) or content[index] not in {'"', "\\"}: + fail("build/.env has unsupported escape in value", line_number) + char = content[index] + decoded_chars.append(char) + index += 1 + decoded = "".join(decoded_chars) + else: + if value != value.strip() or any(char in value for char in "#'\""): + fail("build/.env has ambiguous unquoted value", line_number) + decoded = value + if any(ord(char) < 32 or ord(char) == 127 for char in decoded): + fail("build/.env value contains a control character", line_number) + return decoded + +records = [] +seen = set() +for line_number, line in enumerate(text.split("\n"), 1): + if not line.strip() or line.lstrip().startswith("#"): + continue + match = assignment.fullmatch(line) + if match is None: + fail("build/.env line must be exactly KEY=VALUE", line_number) + key, encoded_value = match.groups() + if key in seen: + fail("build/.env contains a duplicate key", line_number) + seen.add(key) + if key in reserved_exact or key.startswith(reserved_prefixes): + fail("build/.env contains a forbidden execution-control key", line_number) + records.append((key, decode_value(encoded_value, line_number))) + +if snapshot_dest: + destination_parent = os.path.dirname(snapshot_dest) + descriptor, temporary = tempfile.mkstemp(prefix=".nddev-env.", dir=destination_parent) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb") as stream: + stream.write(raw) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, snapshot_dest) + except BaseException: + try: + os.close(descriptor) + except OSError: + pass + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise + +print(f"@sha256\t{digest}") +for key, value in records: + print(f"{key}\t{value}") +PY +} + +nddev::assert_private_env_file() { + nddev::parse_env_file "$1" >/dev/null +} + nddev::load_env() { - local env_file + local mode=${1:-full} marketplace_dir=${2:-} + local env_file parsed key val first_record=1 allowed_keys expected_digest="" + case "$mode" in + paths-only) allowed_keys="ZCODE_TARGET ZCODE_BACKUPS_DIR" ;; + full) + if [ -z "$marketplace_dir" ]; then + nddev::log "error" "full build/.env loading requires a validated marketplace directory" + return 2 + fi + allowed_keys="$(python3 -I - "$marketplace_dir" <<'PY' +import json +import pathlib +import re +import sys + +root = pathlib.Path(sys.argv[1]) +placeholder = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") +names = {"ZCODE_TARGET", "ZCODE_BACKUPS_DIR"} +for filename in ( + "cli-config.template.json", + "v2-config.template.json", + "v2-setting.template.json", + "hooks.json", + "mcp.json", +): + path = root / filename + if not path.exists(): + continue + if path.is_symlink() or not path.is_file(): + raise SystemExit("unsafe marketplace environment template") + with path.open(encoding="utf-8") as stream: + value = json.load(stream) + stack = [value] + while stack: + current = stack.pop() + if isinstance(current, str): + names.update(placeholder.findall(current)) + elif isinstance(current, list): + stack.extend(current) + elif isinstance(current, dict): + stack.extend(current.values()) +print(" ".join(sorted(names))) +PY +)" || return 1 + ;; + *) nddev::log "error" "unsupported build/.env load mode"; return 2 ;; + esac env_file="$(nddev::repo_root)/build/.env" - [ -f "$env_file" ] || return 0 - local line key val - while IFS= read -r line || [ -n "$line" ]; do - # Strip leading/trailing whitespace. - line="${line#"${line%%[![:space:]]*}"}" - line="${line%"${line##*[![:space:]]}"}" - # Skip blank lines and comments. - [ -z "$line" ] && continue - case "$line" in \#*) continue ;; esac - # Must be KEY=VALUE. - [[ "$line" == *=* ]] || continue - key="${line%%=*}" - val="${line#*=}" - # Strip surrounding quotes from the value. - val="${val#\"}" ; val="${val%\"}" - val="${val#\'}" ; val="${val%\'}" - # M2: Validate the key is a legal shell identifier before exporting. - # A malformed key (e.g. "MY KEY=v") would make export fail under set -e. - if ! printf '%s' "$key" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then - nddev::log "warn" "skipping invalid env key in build/.env: '$key'" + if [ ! -e "$env_file" ] && [ ! -L "$env_file" ]; then + if [ -n "${NDDEV_ENV_DIGEST:-}" ] && [ "$NDDEV_ENV_DIGEST" != "absent" ]; then + nddev::log "error" "build/.env disappeared between environment-loading phases" + return 1 + fi + # Read by build.sh in the same runner process. + # shellcheck disable=SC2034 + NDDEV_ENV_DIGEST="absent" + return 0 + fi + if [ "${NDDEV_ENV_DIGEST:-}" = "absent" ]; then + nddev::log "error" "build/.env appeared between environment-loading phases" + return 1 + fi + expected_digest="${NDDEV_ENV_DIGEST:-}" + if ! parsed="$(nddev::parse_env_file "$env_file" "$expected_digest")"; then + nddev::log "error" "refusing invalid or unsafe secret source: build/.env" + return 1 + fi + + while IFS=$'\t' read -r key val; do + if [ "$first_record" -eq 1 ]; then + first_record=0 + if [ "$key" != "@sha256" ] || ! printf '%s\n' "$val" | grep -qE '^[0-9a-f]{64}$'; then + nddev::log "error" "internal build/.env parser contract failed" + return 1 + fi + # Read by build.sh in the same runner process. + # shellcheck disable=SC2034 + NDDEV_ENV_DIGEST="$val" continue fi - # Export only if not already set (environment takes precedence). + case " $allowed_keys " in + *" $key "*) ;; + *) continue ;; + esac + # The parser never evaluates shell syntax. Only the two documented path + # keys support a narrowly scoped HOME prefix for portable .env files. + case "$key" in + ZCODE_TARGET | ZCODE_BACKUPS_DIR) + case "$val" in + "\$HOME") val="$HOME" ;; + "\$HOME/"*) val="$HOME/${val#\$HOME/}" ;; + "\${HOME}") val="$HOME" ;; + "\${HOME}/"*) val="$HOME/${val#\$\{HOME\}/}" ;; + esac + ;; + esac if [ -z "${!key+x}" ]; then export "$key=$val" fi - done < "$env_file" + done <<< "$parsed" + if [ "$first_record" -eq 1 ]; then + nddev::log "error" "internal build/.env parser produced no digest" + return 1 + fi } nddev::log() { local level=$1 shift - printf '[%s] %s\n' "$level" "$*" + case "$level" in + error | missing) printf '[%s] %s\n' "$level" "$*" >&2 ;; + *) printf '[%s] %s\n' "$level" "$*" ;; + esac } nddev::section() { printf '\n==> %s\n' "$*" } -# Run a command unless in dry-run mode. In dry-run, print what would run. -nddev::run() { - local -a cmd=("$@") - local rendered - rendered=$(printf " %q" "${cmd[@]}") - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] %s\n' "${rendered# }" - return 0 +nddev::require_option_value() { + local option=$1 value=${2-} + if [ -z "$value" ] || [[ "$value" == -* ]]; then + nddev::log "error" "$option requires a non-empty value; another option token is not a value" + return 2 + fi +} + +nddev::require_option_once() { + local seen=$1 option=$2 + if [ "$seen" -eq 1 ]; then + nddev::log "error" "duplicate option is not allowed: $option" + return 2 fi - "${cmd[@]}" } -# Require a command on PATH. Level: required | optional. nddev::require_cmd() { local name=$1 local level=${2:-required} @@ -83,20 +321,208 @@ nddev::require_cmd() { return 1 fi nddev::log "warn" "optional command not found: $name" - return 0 } -# Ensure a directory exists (mkdir -p), honoring dry-run. +# Return an absolute canonical path. Existing parent symlinks (for example +# /tmp -> /private/tmp on macOS) are resolved; the final path itself is checked +# separately before any mutation. +nddev::canonical_path() { + local path=$1 + python3 -I - "$path" <<'PY' +import os +import pathlib +import sys + +path = sys.argv[1] +if any(ord(char) < 32 or ord(char) == 127 for char in path): + raise SystemExit("path contains a forbidden control character") +if not os.path.isabs(path): + raise SystemExit("path must be absolute") +if any(part in {".", ".."} for part in pathlib.PurePath(path).parts): + raise SystemExit("path must not contain dot traversal") +print(os.path.realpath(path)) +PY +} + +# Validate an install/backup directory endpoint. The endpoint may be absent or +# a real directory, but never a file or symlink. Canonical path is printed. +nddev::validate_directory_endpoint() { + local role=$1 path=$2 canonical + if ! canonical="$(nddev::canonical_path "$path")"; then + nddev::log "error" "invalid $role path" + return 2 + fi + if [ "$canonical" = "/" ]; then + nddev::log "error" "refusing to use filesystem root as $role" + return 2 + fi + if [ -L "$path" ]; then + nddev::log "error" "$role must not be a symlink: $path" + return 2 + fi + if [ -e "$path" ] && [ ! -d "$path" ]; then + nddev::log "error" "$role must be a directory or absent: $path" + return 2 + fi + printf '%s\n' "$canonical" +} + +# Reject nested/equal target and backup roots. Keeping the roots disjoint makes +# backup rotation and rollback containment mechanically provable. +nddev::validate_disjoint_roots() { + local target=$1 backups=$2 + python3 -I - "$target" "$backups" <<'PY' +import os +import sys + +target, backups = map(os.path.realpath, sys.argv[1:]) +if target == backups: + raise SystemExit("target and backup roots are identical") +if os.path.commonpath((target, backups)) in (target, backups): + raise SystemExit("target and backup roots must not contain one another") +PY +} + +nddev::same_filesystem() { + local left=$1 right=$2 + python3 -I - "$left" "$right" <<'PY' +import os +import sys + +left, right = sys.argv[1:] +left_probe = left if os.path.exists(left) else os.path.dirname(left) +right_probe = right if os.path.exists(right) else os.path.dirname(right) +raise SystemExit(0 if os.stat(left_probe).st_dev == os.stat(right_probe).st_dev else 1) +PY +} + +# Return a stable identity for a real filesystem endpoint. Transactions use it +# to reconcile a signal delivered in the narrow window after an atomic rename +# but before the shell can update its in-memory state. +nddev::path_identity() { + local path=$1 expected_kind=${2:-any} + python3 -I - "$path" "$expected_kind" <<'PY' +import os +import stat +import sys + +path, expected_kind = sys.argv[1:] +metadata = os.lstat(path) +if stat.S_ISLNK(metadata.st_mode): + raise SystemExit("filesystem endpoint must not be a symlink") +if expected_kind == "directory" and not stat.S_ISDIR(metadata.st_mode): + raise SystemExit("filesystem endpoint must be a directory") +if expected_kind == "regular" and not stat.S_ISREG(metadata.st_mode): + raise SystemExit("filesystem endpoint must be a regular file") +if expected_kind not in {"any", "directory", "regular"}: + raise SystemExit("unsupported filesystem endpoint kind") +print(f"{metadata.st_dev}:{metadata.st_ino}") +PY +} + +nddev::identity_matches() { + local path=$1 expected_kind=$2 expected_identity=$3 actual_identity + [ -n "$expected_identity" ] || return 1 + actual_identity="$(nddev::path_identity "$path" "$expected_kind" 2>/dev/null)" || return 1 + [ "$actual_identity" = "$expected_identity" ] +} + +# Atomically rename without ever overwriting an endpoint or allowing `mv` to +# reinterpret an occupied directory as a destination parent. Linux and macOS +# both expose a native exclusive-rename primitive; unsupported kernels fail +# closed. The destination is verified to contain the exact source inode. +nddev::rename_noreplace() { + local source=$1 destination=$2 expected_kind=${3:-any} expected_identity=${4:-} + python3 -I - "$source" "$destination" "$expected_kind" "$expected_identity" <<'PY' +import ctypes +import errno +import os +import stat +import sys + +source, destination, expected_kind, expected_identity = sys.argv[1:] +if any( + not path + or not os.path.isabs(path) + or any(ord(char) < 32 or ord(char) == 127 for char in path) + for path in (source, destination) +): + raise SystemExit("exclusive rename requires safe absolute paths") +try: + metadata = os.lstat(source) +except OSError as exc: + raise SystemExit("exclusive rename source is unavailable") from exc +if stat.S_ISLNK(metadata.st_mode): + raise SystemExit("exclusive rename source must not be a symlink") +if expected_kind == "directory" and not stat.S_ISDIR(metadata.st_mode): + raise SystemExit("exclusive rename source must be a directory") +if expected_kind == "regular" and not stat.S_ISREG(metadata.st_mode): + raise SystemExit("exclusive rename source must be a regular file") +if expected_kind not in {"any", "directory", "regular"}: + raise SystemExit("unsupported exclusive rename source kind") +identity = f"{metadata.st_dev}:{metadata.st_ino}" +if expected_identity and identity != expected_identity: + raise SystemExit("exclusive rename source identity changed") +if os.path.lexists(destination): + raise SystemExit("exclusive rename destination already exists") + +libc = ctypes.CDLL(None, use_errno=True) +encoded_source = os.fsencode(source) +encoded_destination = os.fsencode(destination) +if sys.platform.startswith("linux"): + try: + rename = libc.renameat2 + except AttributeError as exc: + raise SystemExit("native exclusive rename is unavailable") from exc + rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + def perform(encoded_from, encoded_to): + return rename(-100, encoded_from, -100, encoded_to, 1) +elif sys.platform == "darwin": + try: + rename = libc.renamex_np + except AttributeError as exc: + raise SystemExit("native exclusive rename is unavailable") from exc + rename.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + def perform(encoded_from, encoded_to): + return rename(encoded_from, encoded_to, 0x00000004) +else: + raise SystemExit("native exclusive rename is unsupported on this platform") +result = perform(encoded_source, encoded_destination) +if result != 0: + error = ctypes.get_errno() + if error in {errno.EEXIST, errno.ENOTEMPTY}: + raise SystemExit("exclusive rename destination became occupied") + raise SystemExit("native exclusive rename failed") + +try: + renamed = os.lstat(destination) +except OSError as exc: + raise SystemExit("exclusive rename destination cannot be verified") from exc +if os.path.lexists(source) or (renamed.st_dev, renamed.st_ino) != (metadata.st_dev, metadata.st_ino): + if not os.path.lexists(source) and os.path.lexists(destination): + if perform(encoded_destination, encoded_source) == 0: + raise SystemExit("exclusive rename source changed during commit; foreign state restored") + raise SystemExit("exclusive rename postcondition failed; destination requires manual recovery") + raise SystemExit("exclusive rename postcondition failed") +PY +} + nddev::ensure_dir() { local dir=$1 if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] mkdir -p %q\n' "$dir" + printf '[DRY-RUN] mkdir -p -m 700 %q\n' "$dir" return 0 fi - mkdir -p "$dir" + if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then + nddev::log "error" "refusing unsafe directory endpoint: $dir" + return 1 + fi + mkdir -p "$dir" || return 1 + chmod 700 "$dir" || return 1 } -# Copy a file or directory tree, honoring dry-run. $1=src $2=dest. nddev::copy() { local src=$1 dest=$2 if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then @@ -106,149 +532,527 @@ nddev::copy() { cp -R "$src" "$dest" } -# Move a path, honoring dry-run. $1=src $2=dest. -nddev::move() { - local src=$1 dest=$2 - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf '[DRY-RUN] mv %q %q\n' "$src" "$dest" - return 0 - fi - mv "$src" "$dest" -} - -# Detect the platform: darwin -> macos, linux -> ubuntu. nddev::detect_platform() { case "$(uname -s)" in Darwin) printf 'macos\n' ;; Linux) printf 'ubuntu\n' ;; - *) nddev::log "error" "Unsupported OS: $(uname -s)"; return 1 ;; + *) nddev::log "error" "unsupported OS: $(uname -s)"; return 1 ;; esac } -# Read the current installed build version from BUILD-VERSION, or "unknown". -# Reads from NDDEV_TARGET if set (testing), otherwise the real ~/.zcode. -nddev::current_version() { - local zcode_home="${NDDEV_TARGET:-$HOME/.zcode}" - local stamp="$zcode_home/BUILD-VERSION" - if [ -f "$stamp" ]; then - python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('build_version','unknown'))" "$stamp" 2>/dev/null \ - || printf 'unknown' - else - printf 'unknown' - fi +nddev::is_semver() { + python3 -I - "$NDDEV_SEMVER_PATTERN" "$1" <<'PY' +import re +import sys + +pattern, value = sys.argv[1:] +raise SystemExit(0 if re.fullmatch(pattern, value) else 1) +PY } -# Compute today's date in DD.MM.YYYY. -nddev::today() { - date +%d.%m.%Y +# Validate a managed BUILD-VERSION stamp and print its sanitized build version. +# The complete schema is required so a marker file alone cannot authorize a +# destructive remove or restore. +nddev::stamp_version() { + local tree=$1 + python3 -I - "$tree/BUILD-VERSION" "$NDDEV_SEMVER_PATTERN" <<'PY' +import datetime as dt +import json +import re +import sys + +path, semver_pattern = sys.argv[1:] +semver = re.compile(semver_pattern) +try: + with open(path, encoding="utf-8") as stream: + data = json.load(stream) +except (OSError, json.JSONDecodeError) as exc: + raise SystemExit(f"invalid BUILD-VERSION: {exc}") +if not isinstance(data, dict): + raise SystemExit("BUILD-VERSION must be a JSON object") +# Schema 0 is the fully populated legacy 2.0.0 stamp (no explicit schema key). +# New stamps are schema 1; every other version fails closed. +schema = data.get("schema", 0) +if isinstance(schema, bool) or schema not in {0, 1}: + raise SystemExit("unsupported BUILD-VERSION schema") +for key in ("build_version", "zcode_app_version", "zcode_cli_version"): + value = data.get(key) + if not isinstance(value, str) or not semver.fullmatch(value): + raise SystemExit(f"BUILD-VERSION.{key} must be SemVer") +runtime = data.get("zcode_runtime") +if not isinstance(runtime, str) or not runtime.strip() or len(runtime) > 128: + raise SystemExit("BUILD-VERSION.zcode_runtime must be a non-empty string") +if data.get("platform") not in {"macos", "ubuntu"}: + raise SystemExit("BUILD-VERSION.platform must be macos or ubuntu") +installed_at = data.get("installed_at") +if not isinstance(installed_at, str): + raise SystemExit("BUILD-VERSION.installed_at must be a UTC timestamp") +try: + parsed = dt.datetime.fromisoformat(installed_at.replace("Z", "+00:00")) +except ValueError as exc: + raise SystemExit("BUILD-VERSION.installed_at is invalid") from exc +if parsed.tzinfo is None or parsed.utcoffset() != dt.timedelta(0): + raise SystemExit("BUILD-VERSION.installed_at must include UTC timezone") +print(data["build_version"]) +PY +} + +nddev::current_version() { + local zcode_home="${NDDEV_TARGET:-${ZCODE_HOME:-$HOME/.zcode}}" + if [ ! -e "$zcode_home" ]; then + printf 'unmanaged\n' + return 0 + fi + if [ -L "$zcode_home" ] || [ ! -d "$zcode_home" ]; then + nddev::log "error" "install target is not a real directory: $zcode_home" >&2 + return 1 + fi + if [ ! -f "$zcode_home/BUILD-VERSION" ] || [ -L "$zcode_home/BUILD-VERSION" ]; then + printf 'unmanaged\n' + return 0 + fi + nddev::stamp_version "$zcode_home" } -# Compute the next backup slot: 0-9 (ten slots). -# Selection: the lowest free slot (0..9). If all 10 are taken, reuse the OLDEST -# slot (lowest mtime) — its old backup is removed before the new one is moved in. -# The pool never exceeds 10 directories, regardless of version differences. -# -# Reads NDDEV_BACKUPS_DIR if set (testing), otherwise ~/.zcode-backups. +# Choose a direct backup slot. Corrupt, duplicate, or symlinked slot entries +# fail closed instead of being ignored by rotation. nddev::backup_slot() { - local backups_dir="${NDDEV_BACKUPS_DIR:-$HOME/.zcode-backups}" - local slot i - - # Find the lowest free slot 0..9. - slot="" - for i in $(seq 0 9); do - if ! find "$backups_dir" -maxdepth 1 -name "${i}-*-old.zcode" -print -quit 2>/dev/null | grep -q .; then - slot=$i - break - fi - done + local backups_dir="${BACKUPS_DIR:-${NDDEV_BACKUPS_DIR:-$HOME/.zcode-backups}}" + python3 -I - "$backups_dir" "$NDDEV_SEMVER_PATTERN" <<'PY' +import os +import re +import sys + +root = os.path.realpath(sys.argv[1]) +semver = sys.argv[2] +if not os.path.isdir(root): + print(0) + raise SystemExit(0) +pattern = re.compile(rf"^([0-9])-(unmanaged|{semver})-old\.zcode$") +slots = {} +for entry in os.scandir(root): + match = pattern.fullmatch(entry.name) + if not match: + if re.fullmatch(r"[0-9]-.*-old\.zcode", entry.name): + raise SystemExit("invalid backup slot name") + if entry.name.startswith(".slot-") and ".hold." in entry.name: + raise SystemExit("stale backup recovery hold requires attention") + continue + slot = int(match.group(1)) + if entry.is_symlink() or not entry.is_dir(follow_symlinks=False): + raise SystemExit("unsafe backup slot entry") + if slot in slots: + raise SystemExit(f"duplicate backup slot: {slot}") + slots[slot] = (entry.stat(follow_symlinks=False).st_mtime_ns, entry.name) +for slot in range(10): + if slot not in slots: + print(slot) + raise SystemExit(0) +print(min(slots, key=lambda value: (slots[value][0], value))) +PY +} - # If no free slot, reuse the oldest (lowest mtime). - # Portable: BSD stat -f on Darwin, GNU find -printf on Linux. +nddev::backup_name() { + local version=$1 slot=${2:-} + if [ "$version" != "unmanaged" ] && ! nddev::is_semver "$version"; then + nddev::log "error" "unsafe backup version: $version" + return 2 + fi if [ -z "$slot" ]; then - local oldest_name - case "$(uname -s)" in - Darwin) - oldest_name="$(find "$backups_dir" -maxdepth 1 -name "*-old.zcode" -print0 2>/dev/null \ - | xargs -0 stat -f '%m %N' 2>/dev/null | sort -n | head -1 | sed 's#.*/##')" - ;; - *) - oldest_name="$(find "$backups_dir" -maxdepth 1 -name "*-old.zcode" -printf '%T@ %f\n' 2>/dev/null \ - | sort -n | head -1 | sed 's/.* //')" - ;; - esac - slot="${oldest_name%%-*}" - [ -z "$slot" ] && slot=0 + slot="$(nddev::backup_slot)" || return 1 fi + case "$slot" in [0-9]) ;; *) nddev::log "error" "invalid backup slot: $slot"; return 2 ;; esac + printf '%s-%s-old.zcode\n' "$slot" "$version" +} - printf '%s\n' "$slot" +# Assert that candidate is a direct child of parent, with no symlink endpoint. +nddev::assert_direct_child() { + local parent=$1 candidate=$2 + python3 -I - "$parent" "$candidate" <<'PY' +import os +import sys + +parent, candidate = map(os.path.realpath, sys.argv[1:]) +if os.path.dirname(candidate) != parent: + raise SystemExit("path escapes its parent") +PY + if [ -L "$candidate" ]; then + nddev::log "error" "refusing symlinked child: $candidate" + return 1 + fi } -# Compute the backup directory name: --old.zcode -# The slot (0-9) is reused when full, overwriting regardless of version. -# $1 = the version string being backed up. -nddev::backup_name() { - local version=$1 - printf '%s-%s-old.zcode\n' "$(nddev::backup_slot)" "$version" +# Move the exact inode to an unpredictable same-parent quarantine using the +# native no-replace primitive, then remove only that quarantine. This prevents +# a replacement at the public endpoint from being consumed by cleanup. +nddev::remove_identity_bound_path() { + local parent=$1 candidate=$2 expected_kind=$3 expected_identity=$4 + python3 -I - "$parent" "$candidate" "$expected_kind" "$expected_identity" <<'PY' +import ctypes +import os +import shutil +import stat +import sys +import tempfile + +parent, candidate, expected_kind, expected_identity = sys.argv[1:] +parent = os.path.realpath(parent) +if os.path.dirname(os.path.realpath(candidate)) != parent: + raise SystemExit("identity-bound cleanup path escapes its parent") +name = os.path.basename(candidate) +metadata = os.lstat(candidate) +if stat.S_ISLNK(metadata.st_mode): + raise SystemExit("identity-bound cleanup source must not be a symlink") +if expected_kind == "directory" and not stat.S_ISDIR(metadata.st_mode): + raise SystemExit("identity-bound cleanup source must be a directory") +if expected_kind == "regular" and not stat.S_ISREG(metadata.st_mode): + raise SystemExit("identity-bound cleanup source must be a regular file") +identity = f"{metadata.st_dev}:{metadata.st_ino}" +if identity != expected_identity: + raise SystemExit("identity-bound cleanup source changed") + +parent_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0) +parent_fd = os.open(parent, parent_flags) +quarantine_path = tempfile.mkdtemp(prefix=".nddev-delete.", dir=parent) +os.rmdir(quarantine_path) +quarantine_name = os.path.basename(quarantine_path) + +def native_rename_noreplace(source_name, destination_name): + libc = ctypes.CDLL(None, use_errno=True) + source = os.fsencode(source_name) + destination = os.fsencode(destination_name) + if sys.platform.startswith("linux"): + rename = libc.renameat2 + rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + result = rename(parent_fd, source, parent_fd, destination, 1) + elif sys.platform == "darwin": + rename = libc.renameatx_np + rename.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint] + rename.restype = ctypes.c_int + result = rename(parent_fd, source, parent_fd, destination, 0x00000004) + else: + raise SystemExit("native exclusive cleanup rename is unsupported") + if result != 0: + raise SystemExit("native exclusive cleanup rename failed") + +try: + # Revalidate through the opened parent immediately before consuming the + # public name, then verify that the exact inode arrived in quarantine. + before = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if (before.st_dev, before.st_ino) != (metadata.st_dev, metadata.st_ino): + raise SystemExit("identity-bound cleanup source changed") + native_rename_noreplace(name, quarantine_name) + quarantined = os.stat(quarantine_name, dir_fd=parent_fd, follow_symlinks=False) + if (quarantined.st_dev, quarantined.st_ino) != (metadata.st_dev, metadata.st_ino): + try: + native_rename_noreplace(quarantine_name, name) + except BaseException as exc: + raise SystemExit(f"cleanup quarantine requires manual recovery: {quarantine_path}") from exc + raise SystemExit("identity-bound cleanup source changed during quarantine; foreign state restored") + if expected_kind == "directory": + if not shutil.rmtree.avoids_symlink_attacks: + raise SystemExit(f"fd-safe directory cleanup is unavailable; preserved: {quarantine_path}") + # Python 3.10 lacks shutil.rmtree(dir_fd=...). Pin the process cwd to + # the already opened parent instead; the symlink-safe rmtree variant + # then resolves the unpredictable quarantine name through that fd. + os.fchdir(parent_fd) + shutil.rmtree(quarantine_name) + else: + descriptor = os.open( + quarantine_name, + os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0), + dir_fd=parent_fd, + ) + try: + opened = os.fstat(descriptor) + if (opened.st_dev, opened.st_ino) != (metadata.st_dev, metadata.st_ino): + raise SystemExit(f"file cleanup quarantine changed; preserved: {quarantine_path}") + os.unlink(quarantine_name, dir_fd=parent_fd) + finally: + os.close(descriptor) +finally: + os.close(parent_fd) +PY +} + +nddev::remove_direct_child_tree() { + local parent=$1 candidate=$2 expected_identity=${3:-} + nddev::assert_direct_child "$parent" "$candidate" || return 1 + if [ -e "$candidate" ]; then + [ -d "$candidate" ] || { nddev::log "error" "expected directory: $candidate"; return 1; } + if [ -n "$expected_identity" ]; then + nddev::remove_identity_bound_path "$parent" "$candidate" directory "$expected_identity" + else + rm -rf -- "$candidate" + fi + fi } -# Render a JSON template by substituting ${VAR} placeholders from the environment -# (loaded from build/.env). Writes the rendered file. $1=template $2=dest. -# Uses python3 so escaping is safe. Unknown placeholders are left as-is. +nddev::remove_regular_file() { + local candidate=$1 expected_identity=${2:-} + if [ -e "$candidate" ] || [ -L "$candidate" ]; then + if [ ! -f "$candidate" ] || [ -L "$candidate" ]; then + nddev::log "error" "expected regular file: $candidate" + return 1 + fi + if [ -n "$expected_identity" ]; then + nddev::remove_identity_bound_path \ + "$(dirname "$candidate")" "$candidate" regular "$expected_identity" + else + rm -f -- "$candidate" + fi + fi +} + +# Managed state may contain only ordinary directories and uniquely linked +# regular files. This excludes symlink traversal, device/FIFO/socket copying, +# and hard-link aliases whose chmod could mutate data outside the tree. +nddev::assert_safe_tree() { + local root=$1 + python3 -I - "$root" <<'PY' +import os +import stat +import sys + +root = sys.argv[1] +for directory, names, files in os.walk(root, topdown=True, followlinks=False): + for name in names + files: + path = os.path.join(directory, name) + mode = os.lstat(path).st_mode + if stat.S_ISLNK(mode): + raise SystemExit("symlink is not allowed in managed state") + if stat.S_ISDIR(mode): + continue + if not stat.S_ISREG(mode): + raise SystemExit("special file is not allowed in managed state") + if os.lstat(path).st_nlink != 1: + raise SystemExit("hard-linked file is not allowed in managed state") +PY +} + +# JSON templates are parsed before recursive substitution. This preserves JSON +# escaping for arbitrary secret values and never falls back to raw text. nddev::render_template() { local template=$1 dest=$2 if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - printf "[DRY-RUN] render %q -> %q (substitute \${VAR} from env)\n" "$template" "$dest" + # shellcheck disable=SC2016 # ${VAR} is documentation in the plan output. + printf '[DRY-RUN] render JSON %q -> %q (recursive ${VAR} substitution)\n' "$template" "$dest" return 0 fi - local env_file - env_file="$(nddev::repo_root)/build/.env" - python3 - "$template" "$dest" "$env_file" <<'PY' + python3 -I - "$template" "$dest" <<'PY' || return 1 import json import os import re import sys +import tempfile + +template_path, dest_path = sys.argv[1:] +with open(template_path, encoding="utf-8") as stream: + data = json.load(stream) +if not isinstance(data, dict): + raise SystemExit(f"JSON template must contain an object: {template_path}") +data.pop("_comment", None) +placeholder = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") + +def substitute(value): + if isinstance(value, str): + def replace(match): + replacement = os.environ.get(match.group(1)) + return replacement if replacement not in (None, "") else match.group(0) + return placeholder.sub(replace, value) + if isinstance(value, list): + return [substitute(item) for item in value] + if isinstance(value, dict): + return {key: substitute(item) for key, item in value.items()} + return value + +rendered = substitute(data) +directory = os.path.dirname(dest_path) +fd, temporary = tempfile.mkstemp(prefix=".nddev-json.", dir=directory, text=True) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump(rendered, stream, indent=2, ensure_ascii=False) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, dest_path) +except BaseException: + try: + os.unlink(temporary) + except FileNotFoundError: + pass + raise +PY + chmod 600 "$dest" || return 1 +} + +nddev::validate_json() { + local path=$1 + python3 -I - "$path" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as stream: + value = json.load(stream) +if not isinstance(value, dict): + raise SystemExit("expected a JSON object") +PY +} + +# Every active rendered branch must be concrete. Only explicitly disabled +# provider and MCP server nodes may retain dormant placeholders. +nddev::validate_required_placeholders() { + local cli_config=$1 provider_config=$2 setting_config=$3 + python3 -I - "$cli_config" "$provider_config" "$setting_config" <<'PY' +import json +import re +import sys + +placeholder = re.compile(r"\$\{[A-Za-z_][A-Za-z0-9_]*\}") + +def child_path(path, key): + return f"{path}[{key!r}]" -template_path, dest_path, env_file = sys.argv[1], sys.argv[2], sys.argv[3] +def unresolved_keys(value, path): + failures = [] + if isinstance(value, list): + for index, item in enumerate(value): + failures.extend(unresolved_keys(item, f"{path}[{index}]")) + elif isinstance(value, dict): + for key, item in value.items(): + location = child_path(path, key) + if isinstance(key, str) and placeholder.search(key): + failures.append(f"{location}.") + failures.extend(unresolved_keys(item, location)) + return failures -with open(template_path, "r", encoding="utf-8") as f: - raw = f.read() +def unresolved_values(value, path): + failures = [] + if isinstance(value, str) and placeholder.search(value): + failures.append(path) + elif isinstance(value, list): + for index, item in enumerate(value): + failures.extend(unresolved_values(item, f"{path}[{index}]")) + elif isinstance(value, dict): + for key, item in value.items(): + failures.extend(unresolved_values(item, child_path(path, key))) + return failures -# Read build/.env if present and export into os.environ for substitution. -# (load_env in common.sh already did this for the shell process; this is a -# belt-and-suspenders so render_template works even if called directly.) -if os.path.isfile(env_file): - with open(env_file, "r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or "=" not in line: +with open(sys.argv[1], encoding="utf-8") as stream: + cli = json.load(stream) +with open(sys.argv[2], encoding="utf-8") as stream: + providers = json.load(stream) +with open(sys.argv[3], encoding="utf-8") as stream: + settings = json.load(stream) +failures = [] +for root_name, value in ( + ("provider-config", providers), + ("setting", settings), + ("cli", cli), +): + failures.extend(unresolved_keys(value, root_name)) + +for key, value in providers.items(): + if key != "provider": + failures.extend(unresolved_values(value, child_path("provider-config", key))) + continue + if not isinstance(value, dict): + failures.extend(unresolved_values(value, child_path("provider-config", key))) + continue + for name, provider in value.items(): + if isinstance(provider, dict) and provider.get("enabled") is False: + continue + failures.extend(unresolved_values(provider, child_path("provider", name))) + +failures.extend(unresolved_values(settings, "setting")) + +for key, value in cli.items(): + if key != "mcp": + failures.extend(unresolved_values(value, child_path("cli", key))) + continue + if not isinstance(value, dict): + failures.extend(unresolved_values(value, child_path("cli", key))) + continue + for mcp_key, mcp_value in value.items(): + if mcp_key != "servers": + failures.extend(unresolved_values(mcp_value, child_path("mcp", mcp_key))) + continue + if not isinstance(mcp_value, dict): + failures.extend(unresolved_values(mcp_value, child_path("mcp", mcp_key))) + continue + for name, server in mcp_value.items(): + if isinstance(server, dict) and server.get("enabled") is False: continue - key, _, val = line.partition("=") - key = key.strip() - val = val.strip().strip('"').strip("'") - os.environ.setdefault(key, val) + failures.extend(unresolved_values(server, child_path("mcp.servers", name))) +if failures: + raise SystemExit("unresolved required placeholder(s): " + ", ".join(failures)) +PY +} -def repl(match): - var = match.group(1) - return os.environ.get(var, match.group(0)) +nddev::normalize_tree_permissions() { + local root=$1 + [ -d "$root" ] && [ ! -L "$root" ] || return 1 + nddev::assert_safe_tree "$root" || return 1 + python3 -I - "$root" <<'PY' +import os +import sys + +root = sys.argv[1] +for directory, names, files in os.walk(root, topdown=True, followlinks=False): + os.chmod(directory, 0o700) + for name in files: + path = os.path.join(directory, name) + relative = os.path.relpath(path, root) + if relative in { + ".env", + "BUILD-VERSION", + "cli/config.json", + "v2/config.json", + "v2/credentials.json", + } or name == "credentials.json": + os.chmod(path, 0o600) +PY +} -rendered = re.sub(r"\$\{([A-Z0-9_]+)\}", repl, raw) +nddev::sync_directory() { + local directory=$1 + python3 -I - "$directory" <<'PY' +import os +import sys -# Strip the leading "_comment" key so the rendered file is clean runtime config. +descriptor = os.open(sys.argv[1], os.O_RDONLY) try: - data = json.loads(rendered) - data.pop("_comment", None) - with open(dest_path, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, ensure_ascii=False) - f.write("\n") -except json.JSONDecodeError: - # Not JSON (or intentionally not JSON): write the rendered text as-is. - with open(dest_path, "w", encoding="utf-8") as f: - f.write(rendered) + os.fsync(descriptor) +finally: + os.close(descriptor) PY } -# Validate that a file is well-formed JSON. Returns non-zero on failure. -nddev::validate_json() { - local path=$1 - python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$path" +# Persist a fully prepared tree before its atomic rename becomes visible. +nddev::sync_tree() { + local root=$1 + python3 -I - "$root" <<'PY' +import os +import sys + +root = sys.argv[1] +directories = [] +for directory, names, files in os.walk(root, topdown=True, followlinks=False): + directories.append(directory) + for name in files: + path = os.path.join(directory, name) + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) +for directory in reversed(directories): + descriptor = os.open(directory, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) +PY } diff --git a/cli-tools/scripts/lib/version.sh b/cli-tools/scripts/lib/version.sh index 68561e0..b9fb61f 100644 --- a/cli-tools/scripts/lib/version.sh +++ b/cli-tools/scripts/lib/version.sh @@ -1,89 +1,256 @@ #!/usr/bin/env bash # Version helpers for the nddev-zcode-app installer. -# Reads the build version from build/version.json and writes the BUILD-VERSION -# stamp into the target ~/.zcode directory. Also checks the running ZCode -# version against the pinned baseline and warns on mismatch. -# Read a field from build/version.json. $1 = key. nddev::version_field() { - local key=$1 - local root version_json + local key=$1 root version_json root="$(nddev::repo_root)" version_json="$root/build/version.json" if [ ! -f "$version_json" ]; then nddev::log "error" "missing build/version.json" return 1 fi - python3 -c "import json,sys; v=json.load(open(sys.argv[1])).get(sys.argv[2],'unknown'); print(v)" "$version_json" "$key" 2>/dev/null \ - || printf 'unknown' + python3 -I - "$version_json" "$key" <<'PY' +import json +import sys + +path, key = sys.argv[1:] +with open(path, encoding="utf-8") as stream: + data = json.load(stream) +if not isinstance(data, dict) or key not in data: + raise SystemExit(f"missing build/version.json field: {key}") +value = data[key] +if not isinstance(value, str): + raise SystemExit(f"build/version.json field must be a string: {key}") +print(value) +PY } -# Read the build version string from build/version.json. nddev::build_version() { - nddev::version_field build_version + local value + value="$(nddev::version_field build_version)" || return 1 + nddev::is_semver "$value" || { nddev::log "error" "invalid build_version: $value"; return 1; } + printf '%s\n' "$value" } -# Read the pinned ZCode app version from build/version.json. nddev::pinned_app_version() { - nddev::version_field zcode_app_version + local value + value="$(nddev::version_field zcode_app_version)" || return 1 + nddev::is_semver "$value" || { nddev::log "error" "invalid zcode_app_version: $value"; return 1; } + printf '%s\n' "$value" } -# Read the pinned ZCode CLI version from build/version.json. nddev::pinned_cli_version() { - nddev::version_field zcode_cli_version + local value + value="$(nddev::version_field zcode_cli_version)" || return 1 + nddev::is_semver "$value" || { nddev::log "error" "invalid zcode_cli_version: $value"; return 1; } + printf '%s\n' "$value" } -# Read the ZCode runtime baseline from build/version.json. nddev::zcode_runtime() { - nddev::version_field zcode_runtime + local value + value="$(nddev::version_field zcode_runtime)" || return 1 + [ -n "$value" ] || { nddev::log "error" "zcode_runtime is empty"; return 1; } + printf '%s\n' "$value" +} + +nddev::linux_deb_contract() { + local root machine key + root="$(nddev::repo_root)" + machine="$(uname -m)" + case "$machine" in + x86_64 | amd64) key="linux-x64-deb" ;; + arm64 | aarch64) key="linux-arm64-deb" ;; + *) return 1 ;; + esac + python3 -I - "$root/build/version.json" "$key" <<'PY' +import json +import re +import sys + +path, key = sys.argv[1:] +with open(path, encoding="utf-8") as stream: + data = json.load(stream) +artifact = data.get("zcode_download_artifacts", {}).get(key, {}) +name = artifact.get("package_name") +version = artifact.get("package_version") +if data.get("schema") != 2: + raise SystemExit("unsupported artifact schema") +if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9][a-z0-9+.-]*", name): + raise SystemExit("invalid pinned DEB package name") +if not isinstance(version, str) or not re.fullmatch(r"[0-9A-Za-z.+:~_-]+", version): + raise SystemExit("invalid pinned DEB package version") +print(f"{name}|{version}") +PY } -# Detect the RUNNING ZCode desktop app version. -# macOS: reads CFBundleShortVersionString from /Applications/ZCode.app. -# Linux: not detectable (no standard install path) — returns "unknown". nddev::detect_app_version() { - local plist + local app plist applications detected contract package_name expected_package_version package_record package_status installed_version marker case "$(uname -s)" in Darwin) - # Check common locations. - for app in "/Applications/ZCode.app" "$HOME/Applications/ZCode.app"; do + applications="${NDDEV_APPLICATIONS_DIR:-/Applications}" + for app in "$applications/ZCode.app" "$HOME/Applications/ZCode.app"; do plist="$app/Contents/Info.plist" - if [ -f "$plist" ]; then - /usr/bin/defaults read "$app/Contents/Info" CFBundleShortVersionString 2>/dev/null && return 0 + if [ -d "$app" ] && [ ! -L "$app" ] && [ -f "$plist" ] && [ ! -L "$plist" ]; then + detected="$(/usr/bin/defaults read "$app/Contents/Info" CFBundleShortVersionString 2>/dev/null || true)" + if nddev::is_semver "$detected"; then + printf '%s\n' "$detected" + return 0 + fi fi done - printf 'unknown' ;; *) - printf 'unknown' + contract="$(nddev::linux_deb_contract 2>/dev/null || true)" + if [ -n "$contract" ] && command -v dpkg-query >/dev/null 2>&1; then + package_name="${contract%%|*}" + expected_package_version="${contract#*|}" + package_record="$(dpkg-query -W -f='${Status}|${Version}' "$package_name" 2>/dev/null || true)" + package_status="${package_record%%|*}" + installed_version="${package_record#*|}" + if [ "$package_status" = "install ok installed" ]; then + if [ "$installed_version" = "$expected_package_version" ]; then + nddev::pinned_app_version + elif printf '%s\n' "$installed_version" | grep -qE '^[0-9A-Za-z.+:~_-]+$'; then + printf 'deb:%s\n' "$installed_version" + else + printf 'deb:invalid\n' + fi + return 0 + fi + fi + marker="${NDDEV_APPIMAGE_INSTALL_DIR:-$HOME/.local/opt/ZCode}/.nddev-app-version" + if [ -f "$marker" ] && [ ! -L "$marker" ]; then + detected="$(sed -n '1p' "$marker")" + nddev::is_semver "$detected" && printf '%s\n' "$detected" || printf 'appimage:invalid\n' + return 0 + fi ;; esac + printf 'unknown\n' +} + +nddev::parse_cli_version() { + local value=$1 + value="$(printf '%s\n' "$value" | head -1 | sed 's/^v//; s/^[^0-9]*//')" + nddev::is_semver "$value" && printf '%s\n' "$value" || printf 'unknown\n' +} + +nddev::probe_cli_version() { + local output + [ "$#" -gt 0 ] || { printf 'unknown\n'; return 0; } + output="$(python3 -I - "$@" <<'PY' +import os +import resource +import signal +import subprocess +import sys +import tempfile + +command = sys.argv[1:] +limit = 64 * 1024 +timeout = 3 + +def constrain_output(): + resource.setrlimit(resource.RLIMIT_FSIZE, (limit, limit)) + +try: + with tempfile.TemporaryDirectory(prefix="nddev-zcode-probe-") as probe_home, tempfile.TemporaryFile() as output: + xdg_config = os.path.join(probe_home, "config") + xdg_cache = os.path.join(probe_home, "cache") + xdg_data = os.path.join(probe_home, "data") + xdg_state = os.path.join(probe_home, "state") + for directory in (xdg_config, xdg_cache, xdg_data, xdg_state): + os.mkdir(directory, 0o700) + child_env = { + "HOME": probe_home, + "PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "TMPDIR": probe_home, + "XDG_CONFIG_HOME": xdg_config, + "XDG_CACHE_HOME": xdg_cache, + "XDG_DATA_HOME": xdg_data, + "XDG_STATE_HOME": xdg_state, + } + for key in ("LANG", "LC_ALL", "LC_CTYPE", "TZ"): + value = os.environ.get(key) + if value: + child_env[key] = value + process = subprocess.Popen( + [*command, "--version"], + cwd=probe_home, + env=child_env, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.DEVNULL, + start_new_session=True, + preexec_fn=constrain_output, + ) + timed_out = False + try: + returncode = process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + returncode = process.wait() + # The launcher may have forked a background descendant and exited. + # Always terminate the dedicated process group before returning. + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + output.seek(0) + captured = output.read(limit + 1) +except (OSError, subprocess.SubprocessError): + print("unknown") + raise SystemExit(0) + +if timed_out or returncode != 0 or len(captured) > limit: + print("unknown") +else: + print(captured.decode("utf-8", errors="replace").splitlines()[0] if captured else "unknown") +PY +)" || output="unknown" + nddev::parse_cli_version "$output" } -# Detect the RUNNING ZCode CLI version (if the `zcode` binary is on PATH). nddev::detect_cli_version() { - if command -v zcode >/dev/null 2>&1; then - local ver - ver="$(zcode --version 2>/dev/null | head -1 | sed 's/^v//; s/^[^0-9]*//' || true)" - # M3: sed exits 0 even when it produces an empty string (non-numeric first - # line). Default to "unknown" so check_runtime_version doesn't emit a - # spurious mismatch warning with an empty version. - [ -z "$ver" ] && ver='unknown' - printf '%s\n' "$ver" + local executable + executable="$(python3 -I - <<'PY' +import os +import shutil + +path = shutil.which("zcode") +if path: + path = os.path.realpath(path) + if os.path.isfile(path) and os.access(path, os.X_OK): + print(path) +PY +)" + if [ -z "$executable" ]; then + printf 'not-installed\n' + return 0 + fi + nddev::probe_cli_version "$executable" +} + +nddev::detect_cli_version_at() { + local launcher=$1 + if [ -x "$launcher" ] && [ ! -L "$launcher" ]; then + nddev::probe_cli_version "$launcher" else printf 'not-installed\n' fi } -# Compare the running ZCode against the pinned baseline. Warns (does not fail) -# on mismatch — a newer ZCode may work, but this build was verified against the -# pin. Returns 0 always (advisory). +# Advisory by default; pass "strict" for bootstrap postconditions. nddev::check_runtime_version() { - local pinned_app pinned_cli running_app running_cli warnings=0 - - pinned_app="$(nddev::pinned_app_version)" - pinned_cli="$(nddev::pinned_cli_version)" + local mode=${1:-advisory} + local pinned_app pinned_cli running_app running_cli failures=0 + pinned_app="$(nddev::pinned_app_version)" || return 1 + pinned_cli="$(nddev::pinned_cli_version)" || return 1 nddev::section "ZCode version check" if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then nddev::log "info" "pinned app: $pinned_app running: skipped-plan" @@ -94,60 +261,73 @@ nddev::check_runtime_version() { running_app="$(nddev::detect_app_version)" running_cli="$(nddev::detect_cli_version)" - nddev::log "info" "pinned app: $pinned_app running: $running_app" nddev::log "info" "pinned cli: $pinned_cli running: $running_cli" - if [ "$running_app" != "unknown" ] && [ "$running_app" != "$pinned_app" ]; then - nddev::log "warn" "ZCode app $running_app != pinned $pinned_app — this build was verified against $pinned_app; a different version may break." - warnings=$((warnings + 1)) + if [ "$running_app" != "$pinned_app" ]; then + nddev::log "warn" "ZCode app $running_app != pinned $pinned_app" + failures=$((failures + 1)) fi - if [ "$running_cli" != "not-installed" ] && [ "$running_cli" != "unknown" ] && [ "$running_cli" != "$pinned_cli" ]; then - nddev::log "warn" "ZCode CLI $running_cli != pinned $pinned_cli — this build was verified against $pinned_cli." - warnings=$((warnings + 1)) + if [ "$running_cli" != "$pinned_cli" ]; then + nddev::log "warn" "ZCode CLI $running_cli != pinned $pinned_cli" + failures=$((failures + 1)) fi - - if [ "$warnings" -eq 0 ]; then + if [ "$failures" -eq 0 ]; then nddev::log "ok" "running ZCode matches pinned baseline" + elif [ "$mode" = "strict" ]; then + nddev::log "error" "runtime postconditions failed" + return 1 fi - return 0 } -# Write the BUILD-VERSION stamp into the target ZCode home. -# $1 = target path, $2 = selected installer platform (macos|ubuntu). nddev::write_version_stamp() { - local target=$1 - local platform=$2 + local target=$1 platform=$2 local build_version zcode_runtime installed_at app_ver cli_ver + case "$platform" in macos | ubuntu) ;; *) nddev::log "error" "invalid platform: $platform"; return 2 ;; esac - case "$platform" in - macos | ubuntu) ;; - *) - nddev::log "error" "invalid platform for BUILD-VERSION: $platform" - return 2 - ;; - esac - - build_version="$(nddev::build_version)" - zcode_runtime="$(nddev::zcode_runtime)" - app_ver="$(nddev::pinned_app_version)" - cli_ver="$(nddev::pinned_cli_version)" + build_version="$(nddev::build_version)" || return 1 + zcode_runtime="$(nddev::zcode_runtime)" || return 1 + app_ver="$(nddev::pinned_app_version)" || return 1 + cli_ver="$(nddev::pinned_cli_version)" || return 1 installed_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" - if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then printf '[DRY-RUN] write BUILD-VERSION -> %q\n' "$target/BUILD-VERSION" return 0 fi - cat > "$target/BUILD-VERSION" </dev/null || return 1 nddev::log "ok" "wrote BUILD-VERSION ($build_version, $platform, zcode $app_ver)" } diff --git a/cli-tools/scripts/macos/install.sh b/cli-tools/scripts/macos/install.sh index d249d17..ec3b225 100755 --- a/cli-tools/scripts/macos/install.sh +++ b/cli-tools/scripts/macos/install.sh @@ -13,23 +13,23 @@ LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)" . "$LIB_DIR/common.sh" # shellcheck source=lib/version.sh . "$LIB_DIR/version.sh" + +nddev::require_cmd python3 required || exit 1 +nddev::load_env paths-only || exit 1 + +# Load target-aware build state only after the narrow path configuration has +# been validated. Direct runner invocation follows the same contract. # shellcheck source=lib/build.sh . "$LIB_DIR/build.sh" -# Load build/.env (idempotent — env vars already set win, so this is safe even -# when install.sh already loaded them before exec). -nddev::load_env - # Re-select the marketplace (install.sh validated it, but this is a fresh process). nddev::select_marketplace "${NDDEV_MARKETPLACE:?NDDEV_MARKETPLACE must be set by install.sh}" -nddev::log "info" "profile: desktop (macOS)" +# Only now may secrets referenced by trusted marketplace JSON enter this +# process. Existing environment values retain precedence. +nddev::load_env full "$SOURCE_DIR" || exit 1 -# macOS-specific overlay: apply templates from cli-tools/templates/macos/ if present. -OVERLAY_DIR="$(nddev::repo_root)/cli-tools/templates/macos" -if [ -d "$OVERLAY_DIR" ] && [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - nddev::log "info" "macOS overlay dir present: $OVERLAY_DIR (reserved)" -fi +nddev::log "info" "profile: desktop (macOS)" nddev::install_sequence "macos" diff --git a/cli-tools/scripts/restore.sh b/cli-tools/scripts/restore.sh index 8d07773..1aff647 100755 --- a/cli-tools/scripts/restore.sh +++ b/cli-tools/scripts/restore.sh @@ -12,6 +12,8 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh . "$SCRIPT_DIR/lib/common.sh" +nddev::require_cmd python3 required || exit 1 + BACKUP="${NDDEV_BACKUP:-}" TARGET="${NDDEV_TARGET:-}" @@ -19,10 +21,30 @@ if [ -z "$BACKUP" ] || [ -z "$TARGET" ]; then nddev::log "error" "NDDEV_BACKUP and NDDEV_TARGET must be set" exit 2 fi -if [ ! -d "$BACKUP" ]; then - nddev::log "error" "backup directory not found: $BACKUP" +if ! BACKUP="$(nddev::validate_directory_endpoint "restore source" "$BACKUP")"; then + exit 2 +fi +if ! TARGET="$(nddev::validate_directory_endpoint "restore target" "$TARGET")"; then + exit 2 +fi +if [ ! -d "$BACKUP" ] || [ -L "$BACKUP" ]; then + nddev::log "error" "restore source is not a real directory: $BACKUP" + exit 2 +fi +if { [ ! -d "$TARGET" ] || [ -L "$TARGET" ]; } && [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + nddev::log "error" "restore target is not a real directory: $TARGET" exit 2 fi +nddev::assert_safe_tree "$BACKUP" || exit 1 +if [ -d "$TARGET" ]; then + nddev::assert_safe_tree "$TARGET" || exit 1 +fi +if [ "${NDDEV_ALLOW_UNMANAGED_BACKUP:-0}" != "1" ]; then + nddev::stamp_version "$BACKUP" >/dev/null || { + nddev::log "error" "restore source has no valid managed BUILD-VERSION" + exit 1 + } +fi # Files/dirs that are ALWAYS restored (auth + session + runtime data). # Format: "backup-relative path : target-relative path : replace_mode". @@ -72,33 +94,56 @@ for entry in "${RESTORE_PATHS[@]}"; do continue fi - # H2: Normalize dest type mismatch — if src is a dir but dest is a file (or - # vice versa), remove dest first so the copy succeeds without nesting. + # Every restore path is a fixed relative contract. Prove containment again + # before any type normalization or replacement. + python3 -I - "$TARGET" "$dest" <<'PY' +import os +import sys + +root, destination = map(os.path.realpath, sys.argv[1:]) +if os.path.commonpath((root, destination)) != root or destination == root: + raise SystemExit("restore destination escapes target") +PY + + if [ -L "$src" ] || [ -L "$dest" ]; then + nddev::log "error" "refusing symlinked restore path: ${entry%%:*}" + exit 1 + fi + + # Normalize type mismatch so copies never nest into an unexpected endpoint. if [ -e "$dest" ] || [ -L "$dest" ]; then if [ -d "$src" ] && [ ! -d "$dest" ]; then - rm -f "$dest" + rm -f -- "$dest" elif [ ! -d "$src" ] && [ -d "$dest" ]; then - rm -rf "$dest" + rm -rf -- "$dest" fi fi - # H1: For "replace" mode (authoritative dirs), wipe dest first so stale files + # For "replace" mode (authoritative dirs), wipe dest first so stale files # from the fresh build do not survive. For "merge" mode (databases), copy # contents into the existing dest to preserve partial state. if [ -d "$src" ]; then if [ "$mode" = "replace" ] && [ -d "$dest" ]; then - rm -rf "$dest" + rm -rf -- "$dest" fi mkdir -p "$dest" + chmod 700 "$dest" cp -R "$src/." "$dest/" else + [ -f "$src" ] || { nddev::log "error" "restore source is not a regular file: $src"; exit 1; } mkdir -p "$(dirname "$dest")" + chmod 700 "$(dirname "$dest")" cp "$src" "$dest" + chmod 600 "$dest" fi nddev::log "ok" "restored: ${dest_rel} ($mode)" restored=$((restored + 1)) done +if [ "${NDDEV_DRY_RUN:-1}" -eq 0 ]; then + nddev::normalize_tree_permissions "$TARGET" +fi + nddev::log "info" "restored $restored path(s), $skipped absent in backup" if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then diff --git a/cli-tools/scripts/ubuntu/install.sh b/cli-tools/scripts/ubuntu/install.sh index 46c2ca4..b31acaf 100755 --- a/cli-tools/scripts/ubuntu/install.sh +++ b/cli-tools/scripts/ubuntu/install.sh @@ -13,16 +13,22 @@ LIB_DIR="$(cd "$SCRIPT_DIR/../lib" && pwd)" . "$LIB_DIR/common.sh" # shellcheck source=lib/version.sh . "$LIB_DIR/version.sh" + +nddev::require_cmd python3 required || exit 1 +nddev::load_env paths-only || exit 1 + +# Load target-aware build state only after the narrow path configuration has +# been validated. Direct runner invocation follows the same contract. # shellcheck source=lib/build.sh . "$LIB_DIR/build.sh" -# Load build/.env (idempotent — env vars already set win, so this is safe even -# when install.sh already loaded them before exec). -nddev::load_env - # Re-select the marketplace (install.sh validated it, but this is a fresh process). nddev::select_marketplace "${NDDEV_MARKETPLACE:?NDDEV_MARKETPLACE must be set by install.sh}" +# Only now may secrets referenced by trusted marketplace JSON enter this +# process. Existing environment values retain precedence. +nddev::load_env full "$SOURCE_DIR" || exit 1 + # On a headless server there is no desktop app; the ~/.zcode config still applies # to the CLI and any agent sessions run there. PROFILE="desktop" @@ -31,12 +37,6 @@ if [ -z "${DISPLAY:-}" ] && [ -z "${WAYLAND_DISPLAY:-}" ]; then fi nddev::log "info" "profile: $PROFILE (Ubuntu)" -# Ubuntu-specific overlay: apply templates from cli-tools/templates/ubuntu/ if present. -OVERLAY_DIR="$(nddev::repo_root)/cli-tools/templates/ubuntu" -if [ -d "$OVERLAY_DIR" ] && [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then - nddev::log "info" "Ubuntu overlay dir present: $OVERLAY_DIR (reserved)" -fi - nddev::install_sequence "ubuntu" nddev::section "Install complete" diff --git a/cli-tools/templates/macos/.gitkeep b/cli-tools/templates/macos/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/cli-tools/templates/ubuntu/.gitkeep b/cli-tools/templates/ubuntu/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/config/nddev-contract.json b/config/nddev-contract.json index 1c0e3d4..2aae322 100644 --- a/config/nddev-contract.json +++ b/config/nddev-contract.json @@ -1,8 +1,8 @@ { - "contract_version": 1, + "contract_version": 2, "product_name": "nddev-zcode-app", "display_name": "NDDev ZCode App", - "description": "Build system + installer that recreates a complete, version-stamped ~/.zcode from source on macOS/Ubuntu.", + "description": "Build system and installer that recreates a complete, version-stamped ZCode home from a selected portable setup on macOS or Ubuntu.", "github_repository": "NDDev-it-com/nddev-zcode-app", "license": "AGPL-3.0-or-later", "author": { @@ -20,7 +20,8 @@ "marketplace_root": "zcode_tools/marketplaces//marketplace.json", "plugin_manifest": "plugins//.zcode-plugin/plugin.json", "plugin_components_convention": true, - "mcp_key": "mcpServers", + "plugin_mcp_key": "mcpServers", + "installed_mcp_key": "mcp.servers", "hooks_require_enabled_flag": true, "supported_hook_events": [ "SessionStart", @@ -36,9 +37,24 @@ "template": "build/.env.example", "real": "build/.env", "real_gitignored": true, + "source_requirements": "current-user-owned regular non-symlink, 0600 or stricter", + "path_expansion": "exact leading literal $HOME, $HOME/, ${HOME}, or ${HOME}/ prefix only for ZCODE_TARGET and ZCODE_BACKUPS_DIR", "placeholder_syntax": "${VAR_NAME}", - "render_at": "install_time" + "render_at": ["plan_validation", "apply_install"], + "rendering": "structured JSON value substitution with JSON escaping; placeholder-bearing object keys are rejected", + "validation_modes": ["plan", "apply"], + "active_placeholders_required": ["config", "setting", "provider", "mcp", "hooks"], + "disabled_placeholder_exception": ["provider", "mcp"], + "runtime_contains_secrets": true, + "runtime_permissions": "directories 0700; secret-bearing files 0600", + "never_print_or_commit": true }, + "bootstrap_policy_ref": "build/manifest.json:bootstrap_policy", + "command_option_policy_ref": "build/manifest.json:command_option_policy", + "runtime_probe_policy_ref": "build/manifest.json:runtime_probe_policy", + "version_policy_ref": "build/manifest.json:version_policy", + "transaction_policy_ref": "build/manifest.json:transaction_policy", + "adoption_policy_ref": "build/manifest.json:adoption_policy", "backup_policy_ref": "build/manifest.json:backup_policy", "restore_policy_ref": "build/manifest.json:restore_policy" } diff --git a/docs/README.md b/docs/README.md index 7f9d856..384dd36 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,8 @@ ZCode-native component conventions, version contracts, and the public/private repository boundary. - **[install.md](install.md)** covers bootstrap, plan/apply behavior, setup - selection, update, switch, backup, restore, remove, and custom targets. -- **[secrets.md](secrets.md)** defines the local `build/.env` contract and - `${VAR}` rendering behavior. + selection, command-option validity, bounded runtime probing, update, switch, + backup, restore, remove, and custom targets. +- **[secrets.md](secrets.md)** defines source and runtime secret boundaries, + OAuth versus API-key authentication, `${VAR}` rendering, permissions, and + handling requirements. diff --git a/docs/architecture.md b/docs/architecture.md index e2cea1c..c1519d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,14 +1,16 @@ # Architecture `nddev-zcode-app` produces a complete, reproducible ZCode home from source. It -does not run ZCode or own ZCode runtime data. +does not run ZCode agent sessions. Its lifecycle does manage the installed +configuration and only the explicitly declared runtime paths during protected +backup and restore operations. ## Implementation layers ```text zcode_tools/ SOURCE: self-contained marketplace setups cli-tools/ INSTALLER: lifecycle and rendering for macOS and Ubuntu -build/ CONTRACT: versions, manifest, system files, secret template +build/ CONTRACT: versions, artifact integrity, manifest, secret template ``` ### Marketplace sources @@ -27,44 +29,121 @@ installer selects exactly one marketplace and builds the target from it. | `hooks.json`, `mcp.json` | keys in `cli/config.json` | merged | Template rendering expands `${VAR}` values from the process environment and -the local `build/.env` file. Unknown placeholders remain unchanged so optional -provider or tool credentials can be configured later. +the local `build/.env` file through structured JSON substitution. Source +templates never contain real credentials. Rendered `.env`, provider configs, +MCP configs, credentials, and backups are runtime secrets and remain private to +the current user. + +The local env file is accepted only when it is a current-user-owned regular +non-symlink with no group/world permissions. Existing environment variables win +over file values. No shell expansion occurs; only `ZCODE_TARGET` and +`ZCODE_BACKUPS_DIR` recognize a leading literal `$HOME` or `${HOME}` path +prefix. + +### Setup profiles + +- `nddev-builder` enables `core@nddev-builder`, a native component-authoring + toolkit with 13 skills, 13 matching commands, and one reviewer agent. +- `nddev-designer` is a production-ready minimal design profile. Its empty + extension maps are intentional; project-specific design tools come from the + active workspace. +- `nddev-developer` is a production-ready minimal engineering profile. Its + empty extension maps are intentional; language, framework, and repository + tools come from the active workspace. + +All preference templates keep `modelProviderFamilyModes.zai` set to `oauth`, +which is the verified ZCode 3.3.3 account-authentication mode. The provider +objects in `v2/config.json` are a separate explicit API-key contract: Z.ai uses +`https://api.z.ai/api/anthropic`; BigModel uses +`https://open.bigmodel.cn/api/anthropic`. Both API-key providers are disabled +by default and must be enabled deliberately after their secret is configured. ### Installer The entry point is `cli-tools/scripts/install.sh`. Platform runners source the shared libraries and execute the same lifecycle: -1. resolve and validate the selected marketplace, -2. back up a prior stamped target, -3. check the live ZCode runtime in apply mode, -4. render a clean target, -5. write `BUILD-VERSION`, -6. selectively restore runtime state, -7. verify the rendered result. - -Plan mode describes the operation without writes or live `zcode` execution. +1. canonicalize absolute target and backup roots; require existing real + immediate parents; reject files, symlink endpoints, nested roots, + cross-filesystem transactions, and implicit replacement of an unstamped + directory, +2. validate the selected marketplace and acquire an exclusive target lock plus + an exclusive lock for the shared backup pool in deterministic order, +3. create a private same-filesystem sibling stage and check the live ZCode + runtime in apply mode through one canonical executable, a 3-second timeout, + and a 64 KiB output cap, +4. copy source, structurally render JSON and MCP inputs, write `BUILD-VERSION`, + and selectively restore runtime state into the stage, +5. reject unresolved placeholders in keys or values across active + config/setting/provider/MCP/hook branches, symlinks, special files, and + hardlink aliases; normalize private permissions, verify the complete staged + result, and fsync it before commit, +6. hold any occupied rotation slot, move the previous live target into its + backup, and atomically rename the verified stage into place, +7. roll back both the live target and held backup occupant on errors or handled + signals, then release both locks. Every mutable stage/live/rollback/hold + endpoint is bound to its recorded filesystem identity across abort and + committed cleanup; an identity mismatch preserves foreign state, recovery + paths, and locks instead of guessing ownership. + +Plan mode describes the operation without writes or live `zcode` execution, but +still parses, substitutes, merges, and validates config/setting/provider/MCP/hook +inputs. Missing or empty active placeholders in keys or values fail in both +modes; only explicitly disabled provider/MCP nodes may remain dormant. An +existing unstamped directory is never replaced implicitly: initial adoption +requires `--adopt-unmanaged` together with an explicit `--target`. Shared implementation: -- `lib/common.sh` owns logging, target/platform helpers, backup naming, safe - dry-run operations, and template rendering. +- `lib/common.sh` owns logging, canonical path boundaries, backup naming, + private permissions, safe dry-run operations, and structured template + rendering. - `lib/version.sh` owns the public build/runtime version contract and installed stamp. -- `lib/build.sh` owns selection, backup, build, restore, verification, and - orchestration. +- `lib/build.sh` owns selection, two-root locking, staging, backup rotation, + fsync durability, rollback, build, restore, verification, and orchestration. - `restore.sh` applies the explicit per-path restore modes. +### Bootstrap and CLI boundaries + +Bootstrap accepts only the exact canonical CDN base recorded in +`build/version.json` and HTTPS-only redirects. It verifies size plus SHA-512 +before native identity checks. The DEB path is fixed to +`/opt/ZCode/resources/glm/zcode.cjs`. Before the package transaction, private +extraction must find exactly one safe entry there and its CLI version must match +the pin; `dpkg --dry-run -i` must then pass. After installation, the exact +dpkg-owned path/version and SHA-512 equality with the verified payload entry are +required. + +Deterministically ordered locks protect the installer-managed app endpoint and +the user launcher; dpkg owns the system package transaction on Debian systems. +App and launcher swaps retain rollback state until exact postconditions pass. +That point marks the bootstrap committed. Cleanup failure after commit remains +visible but does not roll back verified state; pre-commit errors and handled +signals recover the prior app/launcher when state is unambiguous. New and old +application/launcher endpoints are identity-bound in both abort and success +cleanup, and cleanup uses exclusive quarantine plus fd-relative deletion for +owned state. + +Normal installs treat a missing, timed-out, failed, or over-limit runtime CLI +probe as advisory `not-installed`/`unknown`. Bootstrap treats the same bounded +probe as a strict version postcondition. + ### Build contract -- `VERSION`, `build/version.json`, and `build/manifest.json` carry the same - module build version. -- `build/version.json` also pins the verified ZCode app, CLI, runtime model, CDN, - artifacts, and launcher locations. -- `build/manifest.json` defines public layout, backup, restore, and secrets - contracts. -- The `nddev-builder/core` version is independent and must match between its - marketplace entry and `.zcode-plugin/plugin.json`. +- `VERSION`, `build/version.json`, `build/manifest.json`, the `nddev-builder` + marketplace `core` entry, and the core plugin manifest carry one strict + SemVer for every repository release. +- `build/version.json` also pins the verified ZCode app, CLI, runtime model, + launcher locations, and each CDN artifact's filename, byte size, SHA-512, and + available platform-native identity metadata. Linux launcher entries are + explicit for the DEB (`/opt/ZCode/resources/glm/zcode.cjs`) and the default + AppImage extraction (`${HOME}/.local/opt/ZCode/resources/glm/zcode.cjs`). +- `build/manifest.json` defines public layout, artifact/bootstrap, command-option, + runtime-probe, transaction, backup/restore, adoption, and secrets contracts. +- The release workflow validates every version source, requires the tagged + commit to be reachable from fetched `origin/main`, and rejects publication + before invoking the shared supply-chain workflow if any contract drifts. ## ZCode-native component format @@ -83,6 +162,12 @@ Plugin manifests are metadata, not component registries. User-scope components live directly under `/{skills,commands,agents}/`. Hooks and MCP servers are installed into `/cli/config.json`. +The public product contract is `config/nddev-contract.json` version 2. It keeps +the two MCP namespaces explicit: plugin `.mcp.json` inputs use `mcpServers`, +while the installed CLI configuration uses `mcp.servers`. The installer remains +independent of this descriptive metadata and implements the same mapping +directly. + ## Public/private repository boundary This repository is the public implementation module. It intentionally excludes diff --git a/docs/install.md b/docs/install.md index a78e820..ca17891 100644 --- a/docs/install.md +++ b/docs/install.md @@ -2,8 +2,10 @@ `nddev-zcode-app` can install ZCode **from zero** and then configure it. Two phases: -1. **`bootstrap`** — downloads and installs the ZCode desktop app + CLI (macOS `.dmg`, - Ubuntu `.deb`) at the pinned version, and wires the `zcode` CLI launcher. +1. **`bootstrap`** — downloads and verifies the exact pinned ZCode artifact, + installs the desktop app and embedded CLI, and atomically writes the local + `zcode` launcher. macOS uses a DMG; Ubuntu uses a DEB when the complete dpkg + toolchain is available and a locally extracted AppImage otherwise. 2. **`install`** — builds a clean `~/.zcode` from a marketplace (config, plugins, skills). Backs up the current one first. @@ -11,21 +13,30 @@ Both work on macOS (desktop) and Ubuntu (desktop/server). ## Prerequisites -- Git, Python 3 (`python3`), `curl`, and `node` (the CLI launcher runs the app's - `zcode.cjs` through node). -- `build/.env` populated from `build/.env.example` (see [secrets.md](secrets.md)). -- If ZCode is already installed, `bootstrap` skips the download and only wires - the CLI launcher. +- Git, Python 3.10+ (`python3`), `curl`, and `node` (the CLI launcher runs the + app's `zcode.cjs` through node). +- A local `build/.env` copied from `build/.env.example` only when explicit + API-key providers, MCP integrations, or custom target settings are needed + (see [secrets.md](secrets.md)). When present, it must be a current-user-owned + regular non-symlink with mode `0600` or stricter. Z.ai account OAuth remains + the default. +- macOS bootstrap additionally requires the system `hdiutil`, `codesign`, and + `spctl` tools; it prefers current `diskutil image attach`/`eject` operations + and retains `hdiutil` attach/detach only as a compatibility fallback. Ubuntu + DEB installation requires `dpkg`, `dpkg-deb`, `dpkg-query`, and `sudo` when + not running as root. ## From zero (fresh machine) ```bash # 1. Clone or download the repository. -# 2. Populate secrets: +# 2. Optional: configure explicit API-key providers or local target settings. +# Skip this step when using the default Z.ai account OAuth flow. cp build/.env.example build/.env +chmod 600 build/.env $EDITOR build/.env -# 3. Install the ZCode app + CLI (downloads ~150MB from the official CDN): +# 3. Verify and install the ZCode app + CLI from the official CDN: cli-tools/scripts/install.sh bootstrap --plan # dry-run first cli-tools/scripts/install.sh bootstrap --apply @@ -38,8 +49,58 @@ cli-tools/scripts/install.sh install --marketplace nddev-builder --apply After step 3, ZCode is installed and the `zcode` command is on PATH. After step 4, `~/.zcode` is configured and ready to use. +The installed ZCode home contains sensitive runtime state. Do not print, +commit, upload, or attach its `.env`, rendered provider/MCP configs, +`v2/credentials.json`, or backup contents. + Plan mode performs no writes, downloads, target mutation, or live `zcode` -execution. Runtime detection is deferred until apply mode. +execution. It does parse, substitute, and merge config/setting/provider/MCP/hook +inputs and fails if an active key or value contains a missing or empty +placeholder. Runtime detection is deferred until apply mode. + +## Bootstrap integrity and installed paths + +Bootstrap does not trust an existing installation or an unverified download. +Apply mode performs these checks in order: + +1. Select the platform/architecture artifact object from `build/version.json`. +2. Require the canonical CDN base to be exactly + `https://cdn-zcode.z.ai/zcode/electron/releases`, construct a + credential-free artifact URL, and allow HTTPS-only redirects. +3. Verify the exact byte size and SHA-512 digest before opening the artifact. + Both must match independently; equal size never excuses digest drift. +4. On macOS, verify the DMG, mount it read-only (preferring `diskutil image` + operations), and verify Gatekeeper assessment, code signature, Team ID, + bundle ID, app version, and bundle version before and after installation. +5. On Ubuntu DEB systems, verify package name, architecture, and exact Debian + package version. Extract the payload privately before installation; require + exactly one safe `/opt/ZCode/resources/glm/zcode.cjs` entry and verify its + pinned CLI version. A privileged `dpkg --dry-run -i` preflight must then + succeed before the real `dpkg -i`. After installation, require the same exact + dpkg-owned path and CLI version, and require its SHA-512 to match the verified + payload entry. Installation failure is fatal and never falls through to + another format. +6. When complete dpkg tooling is absent, extract the already verified AppImage, + verify its embedded CLI, and replace its managed install directory through a + same-filesystem stage. +7. Hold deterministic locks for every installer-managed app endpoint and the + user launcher (with dpkg owning its system package transaction), then stage + and swap the app and launcher with rollback state. +8. Require exact app/package, entrypoint, launcher, and CLI postconditions. + These postconditions define the commit point. Cleanup failure after commit + is reported as an error without rolling back verified committed state. + +Default runtime paths are: + +| Platform/package | Embedded CLI entry | +| --- | --- | +| macOS DMG | `/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs` | +| Ubuntu DEB | `/opt/ZCode/resources/glm/zcode.cjs` | +| Ubuntu AppImage | `${HOME}/.local/opt/ZCode/resources/glm/zcode.cjs` | + +All three use `${HOME}/.local/bin/zcode` as the user-facing launcher. The +AppImage path can be changed with `NDDEV_APPIMAGE_INSTALL_DIR`; the macOS app +root can be changed with `NDDEV_APPLICATIONS_DIR` for isolated environments. ## Usage @@ -51,6 +112,13 @@ cli-tools/scripts/install.sh list cli-tools/scripts/install.sh install --marketplace nddev-builder --plan cli-tools/scripts/install.sh install --marketplace nddev-builder --apply +# Explicitly adopt an existing unstamped ZCode home for the first time. +# Both --adopt-unmanaged and an explicit existing --target are required: +cli-tools/scripts/install.sh install --marketplace nddev-builder \ + --target "$HOME/.zcode" --adopt-unmanaged --plan +cli-tools/scripts/install.sh install --marketplace nddev-builder \ + --target "$HOME/.zcode" --adopt-unmanaged --apply + # Update — re-run install with the same marketplace (old ~/.zcode is backed up). # Switch — install a different marketplace (the old setup is backed up). # Remove — back up and delete the install: @@ -63,7 +131,7 @@ cli-tools/scripts/install.sh restore --slot 3 --apply # Custom install directory (default is ~/.zcode): cli-tools/scripts/install.sh install \ - --marketplace nddev-builder --target /opt/my-zcode --apply + --marketplace nddev-builder --target "$HOME/.zcode-work" --apply # ...or set it once in build/.env (ZCODE_TARGET=...) and skip --target. # Force a platform (otherwise auto-detected from uname): @@ -77,18 +145,52 @@ cli-tools/scripts/install.sh install \ | --- | --- | | `bootstrap` | Install the pinned ZCode app and CLI; defaults to plan mode. | | `install` | Back up, build from one marketplace, and restore runtime state. | -| `remove` | Back up and delete a stamped target. | +| `remove` | Atomically move a stamped target into the backup pool. | | `restore` | Restore one backup slot into an empty or stamped target. | | `list` | Show marketplaces; add `--backups` to show backup slots. | +### Command option matrix + +The installer rejects unknown options, options that do not apply to the chosen +command, and simultaneous `--apply` plus `--plan`/`--dry-run`. It never silently +ignores a recognized flag. + +| Option | bootstrap | install | remove | restore | list | +| --- | --- | --- | --- | --- | --- | +| `--marketplace` | — | yes | — | — | — | +| `--target` | — | yes | yes | yes | — | +| `--platform` | yes | yes | — | — | — | +| `--apply`, `--plan`, `--dry-run` | yes | yes | yes | yes | — | +| `--keep-backup` | — | — | yes | — | — | +| `--slot` | — | — | — | yes | — | +| `--adopt-unmanaged` | — | yes | — | — | — | +| `--allow-target-relocation` | — | — | — | yes | — | +| `--backups` | — | — | — | — | yes | + +Use `list`, `-l`, or `--list` to select the list command. `-h`/`--help` prints +usage without executing a command. + ### Target directory resolution -The install/remove target is resolved in this order: +The install, remove, or restore target is resolved in this order: 1. `--target ` flag (highest precedence) 2. `ZCODE_TARGET` in `build/.env` 3. `~/.zcode` (the standard ZCode location, default) +The env parser performs no shell expansion. For portability, only +`ZCODE_TARGET` and `ZCODE_BACKUPS_DIR` support an exact leading literal +`$HOME`, `$HOME/`, `${HOME}`, or `${HOME}/` prefix; other values remain literal. + +Target and backup roots must be canonical absolute paths with existing real +immediate parent directories. They must be non-root, disjoint, and on one +filesystem. Existing files and symlink endpoints are always refused. The +current user must be able to create and replace entries in both parents; a path +such as `/opt/...` therefore requires permissions prepared by an administrator. +An existing unstamped directory is also refused unless `install` receives both +`--adopt-unmanaged` and an explicit existing `--target`; this prevents a typo +from silently replacing an unrelated directory. + ### Lifecycle: install → update → switch → remove Every transition backs up the current install first into a rotating pool of @@ -101,35 +203,78 @@ never grows beyond 10 directories: - **Remove** — `remove` backs up and deletes. Slot selection: the lowest free slot (0–9). When all 10 are full, the **oldest** -slot (by modification time) is overwritten — its old backup is removed first, -regardless of version. The version is in the filename; the timestamp is inside -each backup's `BUILD-VERSION` stamp. +slot (by modification time) is selected regardless of version. Its prior +occupant is held until the new transaction commits and is restored on failure. +Managed backups carry the version in the filename and a validated timestamp in +`BUILD-VERSION`. + +An explicitly adopted unstamped target is stored as +`-unmanaged-old.zcode/`, containing an owner-only `NDDEV-BACKUP.json` +envelope and the original content tree under `payload/`. Adoption normalizes +directory and file permissions to private owner-only modes, so original mode +metadata is intentionally not preserved. The envelope binds the backup to its +original canonical target. ## What happens on `install --apply` -1. **Backup** — the current target is moved to `/--old.zcode`, - where `N` is a 0–9 rotation slot (lowest free slot; the oldest is overwritten - when all ten are full, regardless of version). The pool never exceeds 10 backups. -2. **Build** — a clean target is rendered from the selected marketplace: - - `AGENTS.md`, `skills/`, `commands/`, `agents/`, `plugins/` are copied as-is. +1. **Boundaries and locks** — target and backup roots are validated, then the + installer acquires exclusive target and shared backup-pool locks. Concurrent + operations cannot race the same live tree or rotation pool. +2. **Stage** — a private sibling staging directory is created on the same + filesystem. A clean target is rendered there from the selected marketplace: + - `AGENTS.md` and user-scope `skills/`, `commands/`, and `agents/` are copied + as-is; the complete selected marketplace, including its plugins, is copied + under `marketplaces//`. - `cli/config.json`, `v2/config.json`, `v2/setting.json` are rendered from - their `*.template.json`, with `${VAR}` secrets injected from `build/.env`. + their JSON inputs, with `${VAR}` values structurally substituted and + JSON-escaped. Placeholder-bearing object keys are rejected. Rendered MCP + entries are merged into `cli/config.json`. - Empty runtime directories ZCode expects are created. 3. **Version stamp** — `BUILD-VERSION` records the build version, ZCode runtime baseline, platform, and timestamp. -4. **Restore** — runtime state is selectively restored from the backup: +4. **Restore into stage** — selected runtime state is copied from the current + target before any live replacement: - **Always restored**: `v2/credentials.json`, `v2/certs/`, `cli/agents/` (sessions), `cli/db/`, `cli/artifacts/`. - **Never restored** (regenerated by ZCode): `cli/log/`, `v2/logs/`, `v2/crash/`, `cli/plugins/cache/`. -5. **Verify** — the rendered JSON files are validated; `BUILD-VERSION` and - `AGENTS.md` presence is checked. +5. **Verify** — managed stamp and JSON schemas, marketplace presence, + unresolved active config/setting/provider/MCP/hook placeholders in keys or + values, + symlink/special-file/hardlink absence, and private permissions are checked; + the verified stage is fsynced before commit. Only explicitly disabled + provider/MCP entries may keep dormant placeholders. +6. **Commit or rollback** — an occupied backup slot is held, the previous + target moves into its backup, and the verified stage is atomically renamed + into place. A failed commit, handled signal, or pre-commit finalization step + restores the previous target and backup-slot occupant. Housekeeping failure + after a verified commit is reported without discarding committed state. + Stage, live, rollback, adoption-envelope, and occupied-slot endpoints remain + bound to their recorded filesystem identities through abort and success + cleanup. If any endpoint is replaced, foreign state is left untouched and + recovery paths plus locks are preserved for explicit inspection. ## After install Open the ZCode desktop app. `credentials.json` is restored from the backup, so you stay logged in. If the auth token expired, re-authenticate in the app. +The verified preference uses Z.ai account OAuth. To use an explicit Z.ai API +key instead, populate `ZAI_API_KEY`, set that custom provider's `enabled` field +to `true`, reinstall, and select the provider backed by +`https://api.z.ai/api/anthropic`. BigModel API-key access remains a separate, +disabled-by-default provider backed by `https://open.bigmodel.cn/api/anthropic`; +enable it only after populating `BIGMODEL_API_KEY`. + +### Runtime version probe + +A setup install in apply mode resolves and canonicalizes the `zcode` executable +once, then invokes `--version` with no stdin, a 3-second timeout, and a 64 KiB +output cap. A missing binary reports `not-installed`; timeout, nonzero exit, +oversized output, or probe error reports `unknown`. Those values are advisory +and nonfatal during a normal setup install. Bootstrap uses the same bounded +probe as a strict postcondition and refuses unknown or mismatched CLI state. + ## Reverting Use the guarded restore lifecycle instead of moving backup directories manually: @@ -140,6 +285,41 @@ cli-tools/scripts/install.sh restore --slot --plan cli-tools/scripts/install.sh restore --slot --apply ``` -If the target exists, restore first backs it up. The restore source is staged -before any backup rotation or target replacement so a full 10-slot pool cannot -invalidate the selected source. +If the target exists, restore first backs it up. The restore source is copied +to and verified in a private same-filesystem stage before backup rotation or +target replacement, so a full 10-slot pool cannot invalidate the selected +source. + +Typed adopted-unmanaged envelopes restore only to their recorded original +target by default. Relocation is a separate explicit action: + +```bash +cli-tools/scripts/install.sh restore --slot \ + --target /absolute/new/path --allow-target-relocation --plan +cli-tools/scripts/install.sh restore --slot \ + --target /absolute/new/path --allow-target-relocation --apply +``` + +## Interrupted operations and upstream limits + +Ordinary errors and handled termination signals trigger deterministic rollback +and lock cleanup. An uncatchable `SIGKILL` or power loss can leave a sibling +stage, recovery hold, or lock directory. The installer then fails closed rather +than guessing that the transaction owner is dead. Inspect the lock's `owner` +metadata and reconcile the live target, backup slot, and held state before +removing recovery artifacts; preserve uncertain state for manual recovery. +The locks are advisory against cooperative installer processes; filesystem +identity checks additionally prevent cleanup from deleting a same-path object +that another process substituted before a guarded mutation. + +Ubuntu DEB installation delegates the system package transaction to `dpkg`. +Before the real transaction, the installer verifies the privately extracted +payload and runs `dpkg --dry-run -i`. It detects package-manager and +postcondition failures, but it cannot transactionally undo changes made inside +a real dpkg transaction. Repair the system package-manager state before +retrying; bootstrap never masks a failed DEB by falling through to AppImage. + +The current upstream macOS app is accepted by Gatekeeper as +`source=Notarized Developer ID`, and the installer requires that exact result, +but the DMG does not contain a stapled notarization ticket. If Gatekeeper cannot +establish that assessment, bootstrap fails closed before installation. diff --git a/docs/secrets.md b/docs/secrets.md index c0d47d1..6d6bc6e 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -1,46 +1,96 @@ # Secrets -Secrets never live in `zcode_tools/` (the source of `~/.zcode`) and are never -committed to the repository. They are rendered into the config files at install -time from a local, gitignored `build/.env`. +Repository source and the installed ZCode home have different security +boundaries. Source templates contain placeholders only. The rendered ZCode +home can contain live API keys, tokens, credentials, MCP headers, certificates, +and session state and must be treated as sensitive runtime data. -## Contract +## Source contract -- **`build/.env.example`** — committed. Lists every secret key the build needs, - with empty values and a comment explaining each. This is the template. -- **`build/.env`** — gitignored (`build/.gitignore`). Holds the real values. - Created by copying the example. -- **Placeholders** — config templates in `zcode_tools/` reference secrets as - `${VAR_NAME}`. The installer substitutes each placeholder with the matching - value from `build/.env` (or the process environment). Unknown placeholders are - left as-is so a missing optional secret never breaks the build. - -## Setup +- `build/.env.example` is committed and declares supported keys with empty + values and explanatory comments. +- `build/.env` is local and gitignored. Copy the example only when explicit + API-key providers, integrations, or custom target settings are needed. +- When present, `build/.env` must be a current-user-owned regular non-symlink + file with no group or world permission bits (`0600` or stricter). The + installer rejects the file before reading or copying it otherwise. +- Existing process-environment values take precedence. Only `ZCODE_TARGET` and + `ZCODE_BACKUPS_DIR` expand an exact leading literal `$HOME`, `$HOME/`, + `${HOME}`, or `${HOME}/` prefix. Every other value remains literal. +- Marketplace JSON inputs reference secrets as `${VAR_NAME}`. Every referenced + key must be declared in `build/.env.example`; real values never belong in a + source template. +- `build/.env` is key/value data, not a shell program. Do not execute it with + `source`, `.`, or `eval`. ```bash cp build/.env.example build/.env -$EDITOR build/.env # fill in real values +chmod 600 build/.env +$EDITOR build/.env ``` -## Which templates use secrets +## Authentication modes + +Z.ai account authentication and explicit provider API keys are separate: + +- `modelProviderFamilyModes.zai: oauth` in `v2/setting.json` is the verified + ZCode 3.3.3 default for account login. It does not require `ZAI_API_KEY`. +- `ZAI_API_KEY` configures the explicit custom Z.ai provider at + `https://api.z.ai/api/anthropic`. That provider is disabled by default; set + its `enabled` field to `true` only after supplying the key. +- `BIGMODEL_API_KEY` configures the separate BigModel provider at + `https://open.bigmodel.cn/api/anthropic`; that provider is also disabled by + default and must be enabled deliberately after supplying the key. + +Provider secrets are rendered into `v2/config.json`. MCP secrets referenced in +`mcp.json` are rendered before their entries are merged into `cli/config.json`. +Rendering is structured and JSON-escapes values; env text is never evaluated as +shell syntax. Placeholder-bearing object keys are never substituted and are +rejected instead. Missing and empty values remain visibly unresolved. Plan and +apply both parse, substitute, and merge config, setting, provider, MCP, and hook +inputs, then refuse unresolved placeholders in keys or values throughout every +active branch. Only explicitly disabled provider or MCP entries may remain +dormant. + +## Sensitive runtime paths + +The following installed or backup paths can contain secrets: + +- `~/.zcode/.env` or the equivalent custom target path; +- `~/.zcode/v2/config.json`; +- `~/.zcode/cli/config.json`; +- `~/.zcode/v2/credentials.json`; +- `~/.zcode/v2/certs/` and other restored account/session state; +- `~/.zcode-backups/` or the configured backup directory. + +An adopted-unmanaged backup also contains `NDDEV-BACKUP.json` with the original +canonical target path. Treat that path as private environment metadata and +redact it from shared diagnostics together with the envelope payload. -Templates live inside each marketplace directory: -`zcode_tools/marketplaces//`. +`v2/credentials.json` is unequivocally a secret: it holds ZCode desktop account +authentication tokens. The installer restores it from protected backup state +rather than templating it, but that does not make it safe to disclose. -- `v2-config.template.json` — provider API keys: - `${ZAI_API_KEY}`, `${BIGMODEL_API_KEY}`. -- `mcp.json` / `cli-config.template.json` — MCP server secrets: - `${GITHUB_PERSONAL_ACCESS_TOKEN}`, `${CONTEXT7_API_KEY}`, etc. +Target and backup directories must be private to the current user (`0700`). +Installed secret-bearing files, including `.env`, rendered configs, and +credentials, must be owner-readable and owner-writable only (`0600`). The local +source `build/.env` may be stricter but may never grant group/world access. -## credentials.json is NOT a secret in this sense +## Handling rules -`~/.zcode/v2/credentials.json` holds the ZCode desktop app auth tokens. It is -**restored from the backup** by the installer, never templated and never -committed. This is why backups live under `~/.zcode-backups/` (outside the repo), -not inside the repository tree. +- Never print, trace, commit, upload, attach, or paste runtime secret files or + their values into logs, issues, pull requests, screenshots, or diagnostics. +- Do not place backups inside a repository tree or a shared directory. +- Pass CLI-tool credentials through an approved process environment. Prefer a + project-provided launcher, secrets manager, or non-evaluating parser with an + explicit key allowlist; never shell-source an env file. +- Redact values before reporting configuration errors. It is safe to name a + missing variable, but not to echo its value. +- Rotate any credential immediately if a runtime file or backup was exposed. ## Repository guards Public secret scanning checks tracked content for exposed credentials. The -maintainers' private harness also rejects any tracked `.env` file as a module -boundary violation. Keep real values only in your local `build/.env`. +maintainers' private harness additionally enforces the module boundary and +rejects tracked local env files. Automated guards supplement, but do not +replace, the handling rules above. diff --git a/references/zcode-baseline.json b/references/zcode-baseline.json index 33280e2..6fac9c5 100644 --- a/references/zcode-baseline.json +++ b/references/zcode-baseline.json @@ -1,14 +1,20 @@ { "baseline_version": 1, - "_comment": "ZCode runtime baseline for this build. The app and CLI versions are pinned in build/version.json and verified by the installer at install time. Update verified_date and versions when the baseline changes.", + "_comment": "ZCode runtime baseline for this build. The app and CLI versions are pinned in build/version.json and verified by the installer at install time. Account-mode Z.ai authentication uses the verified OAuth preference; explicit API-key providers are separate custom provider entries. Update verified_date and versions when the baseline changes.", "zcode": { "app_version": "3.3.3", "app_build": "3.3.3.2657", + "linux_deb_package_version": "3.3.3-2657", "cli_version": "0.15.0", "app_bundle_id": "dev.zcode.app", + "macos_team_id": "8A5X4JJ39T", "runtime_model": "GLM-5.2", "provider_domain_default": "zai", "provider_kind": "anthropic", + "provider_account_auth_mode": "oauth", + "provider_api_key_source": "custom", + "provider_zai_api_key_base_url": "https://api.z.ai/api/anthropic", + "provider_bigmodel_api_key_base_url": "https://open.bigmodel.cn/api/anthropic", "marketplace_format": "root marketplace.json (name + owner + plugins[])", "plugin_manifest_path": ".zcode-plugin/plugin.json", "config_file": "~/.zcode/cli/config.json", @@ -20,6 +26,12 @@ "mcp_plugin_file": ".mcp.json", "mcp_plugin_key": "mcpServers" }, + "evidence": { + "official_install_documentation": "https://zcode.z.ai/en/docs/install", + "official_configuration_documentation": "https://zcode.z.ai/en/docs/configuration", + "official_distribution_base": "https://cdn-zcode.z.ai/zcode/electron/releases", + "artifact_contract": "build/version.json:zcode_download_artifacts" + }, "verified_date": "2026-07-10", - "verified_against": "NDDev-it-com/nddev-zcode-app @ 2.0.0" + "verified_against": "ZCode app 3.3.3 (build 3.3.3.2657) and CLI 0.15.0 runtime state" } diff --git a/zcode_tools/marketplaces/nddev-builder/AGENTS.md b/zcode_tools/marketplaces/nddev-builder/AGENTS.md index 22097e8..22367d0 100644 --- a/zcode_tools/marketplaces/nddev-builder/AGENTS.md +++ b/zcode_tools/marketplaces/nddev-builder/AGENTS.md @@ -1,31 +1,54 @@ # Global Agent Instructions - -# ZCode environment — nddev-zcode-app - -This `~/.zcode` directory is produced by **nddev-zcode-app** (`NDDev-it-com/nddev-zcode-app`), -a build system + installer that recreates a complete, version-stamped ZCode environment from -source on macOS (desktop) and Ubuntu (desktop/server). - -## What lives here - -- `AGENTS.md` — this file. User-scope default instructions for every ZCode workspace. -- `skills/`, `commands/`, `agents/` — user-scope skills, slash commands, and subagents. -- `marketplace.json` — the local `nddev-builder` plugin marketplace (installed via Discover → local directory). -- `plugins/` — self-contained plugin bundles (`/.zcode-plugin/plugin.json` + `skills/`, - `commands/`, `agents/`, `.mcp.json`). -- `cli/config.json` — plugin enable/disable state, hooks (`hooks.enabled: true`), and MCP servers - (`mcp.servers`). -- `v2/` — desktop-app state: provider definitions (`config.json`), preferences (`setting.json`), - and credentials (`credentials.json`, restored from backup — never committed to the repo). - -## Operating rules - -- English for code, docs, and commits (Conventional Commits). Sole author: Danil Silantyev. -- Plugins are convention-discovered: `skills//SKILL.md`, `commands/.md`, - `agents/.md`. MCP is centralized in `plugins//.mcp.json` with `{"mcpServers":{}}`. -- Secrets never live in this tree — they are rendered from `build/.env` (gitignored) at install - time. `credentials.json` is restored from the backup, not templated. -- To change this environment, edit the **source** in the `zcode_tools/` directory of the - nddev-zcode-app repo and re-run the installer — do not hand-edit the rendered files here. - + +## ZCode environment — nddev-builder + +This ZCode home is produced from the `nddev-builder` marketplace in +`NDDev-it-com/nddev-zcode-app`. It is a focused environment for creating and +maintaining ZCode-native marketplaces, plugins, skills, commands, agents, +hooks, MCP servers, providers, references, and companion CLI tools. + +The `core@nddev-builder` plugin is enabled by default. Use its components as +the primary workflow for changes to ZCode extension sources. + +### Working contract + +- Inspect the active repository and its instructions before proposing a + component location or format. +- Prefer ZCode-native convention discovery: `skills//SKILL.md`, + `commands/.md`, `agents/.md`, and metadata-only + `.zcode-plugin/plugin.json` manifests. +- Keep marketplace registration, plugin manifests, enabled-plugin keys, and + component paths synchronized in the same change. +- Reuse an existing component when its responsibility already matches. Avoid + aliases, duplicate policy sources, and speculative abstractions. +- Make the smallest complete change, validate its syntax and discovery path, + and report the exact files and checks used. + +### Installed layout + +- `AGENTS.md` provides these user-scope defaults. +- `skills/`, `commands/`, and `agents/` hold optional user-scope components. +- `marketplaces/nddev-builder/` contains the installed marketplace and its + self-contained plugin bundles. +- `cli/config.json` contains plugin state, hooks, and MCP server definitions. +- `v2/config.json` contains explicit API-key providers. +- `v2/setting.json` contains desktop preferences, including the verified Z.ai + account OAuth mode. +- `.env`, provider configs, MCP configuration, `v2/credentials.json`, and + backups can contain credentials or tokens. + +### Safety and quality + +- Repository source must never contain real credentials. Use declared + `${VAR_NAME}` placeholders and the local, gitignored `build/.env` contract. +- The rendered ZCode home does contain sensitive values. Never print, commit, + upload, or include its `.env`, configs, credentials, or backups in examples + and diagnostics. +- Treat `credentials.json` as a secret. It is restored runtime state, not a + source template. +- Do not hand-edit rendered files as the durable fix. Edit the marketplace + source and re-run the installer. +- Keep code, identifiers, documentation, and commit messages in English. Use + Conventional Commits when committing repository changes. + diff --git a/zcode_tools/marketplaces/nddev-builder/cli-config.template.json b/zcode_tools/marketplaces/nddev-builder/cli-config.template.json index a643602..77b9208 100644 --- a/zcode_tools/marketplaces/nddev-builder/cli-config.template.json +++ b/zcode_tools/marketplaces/nddev-builder/cli-config.template.json @@ -2,7 +2,9 @@ "_comment": "Config-file hooks shape: events live under hooks.events. (NOT hooks.). hooks.enabled must be true. See the official diagnosing-hooks skill.", "plugins": { "enabled": true, - "enabledPlugins": {} + "enabledPlugins": { + "core@nddev-builder": true + } }, "hooks": { "enabled": true, diff --git a/zcode_tools/marketplaces/nddev-builder/marketplace.json b/zcode_tools/marketplaces/nddev-builder/marketplace.json index 971969d..bedf4c2 100644 --- a/zcode_tools/marketplaces/nddev-builder/marketplace.json +++ b/zcode_tools/marketplaces/nddev-builder/marketplace.json @@ -10,7 +10,7 @@ "name": "core", "source": "./plugins/core", "description": "Reusable toolkit: skills, slash commands, and a reviewer subagent for building plugins, managing MCP servers and providers, authoring skills, and listing or removing components.", - "version": "2.0.0", + "version": "2.0.1", "author": { "name": "Danil Silantyev (github:rldyourmnd), CEO NDDev" }, diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/.zcode-plugin/plugin.json b/zcode_tools/marketplaces/nddev-builder/plugins/core/.zcode-plugin/plugin.json index 0893c50..43f4da2 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/.zcode-plugin/plugin.json +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/.zcode-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "core", - "version": "2.0.0", + "version": "2.0.1", "description": "Reusable ZCode toolkit: skills, slash commands, and a reviewer subagent for building plugins, managing MCP servers and providers, authoring skills, and listing or removing components.", "author": { "name": "Danil Silantyev (github:rldyourmnd), CEO NDDev", diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-marketplace.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-marketplace.md index 2257fe0..00e405a 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-marketplace.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-marketplace.md @@ -10,8 +10,11 @@ Follow the `add-marketplace` skill exactly: 2. Create the directory tree: `zcode_tools/marketplaces//{skills,commands,agents,plugins}`. 3. Copy the template files from `nddev-designer` (the cleanest minimal example) and edit: - `marketplace.json` → set `name` and `description`. - - `AGENTS.md` → set the `` marker. - - Keep the default `cli-config.template.json`, `v2-config.template.json`, `v2-setting.template.json`, `mcp.json`, `hooks.json`. + - `AGENTS.md` → set the `` marker and write substantive, + purpose-specific operating instructions. + - Keep `recentProjects: []`; update providers and enabled plugins + deliberately. Empty plugins/hooks/MCP are valid only when the profile + documents why workspace-specific tooling is intentional. 4. Validate by running: `cli-tools/scripts/install.sh install --marketplace --plan` — it must pass `validate_marketplace`. 5. Confirm it appears in `install.sh list`. 6. Remind to bump the build version if this is a release change. diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-mcp.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-mcp.md index 7b463c3..4bdfb18 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-mcp.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-mcp.md @@ -6,7 +6,11 @@ Register a tool integration for the agent. Follow the `add-mcp-server` skill exactly. First, help the user decide the path: -1. **Explain the trade-off briefly:** MCP loads schemas permanently (4-32x more tokens); CLI+skill costs zero until called and is more composable. Default to CLI+skill for local-dev; MCP for cross-agent standardized tools. +1. **Explain the trade-off briefly:** MCP exposes standardized tool schemas at + session scope; CLI+skill keeps compact routing metadata in the baseline and + loads detailed guidance/output on demand, but is less constrained. Default + to CLI+skill for local development; use MCP for standardized cross-agent + discovery or a deliberately constrained interface. 2. **Path A (classic MCP):** - Add the server entry to `zcode_tools/marketplaces//mcp.json` under `mcpServers` (stdio or http shape). diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-provider.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-provider.md index 6f24d8f..6c18ea7 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-provider.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-provider.md @@ -8,6 +8,9 @@ Follow the `add-provider` skill exactly: 1. Ask the user for: provider name, API kind (anthropic/openai), base URL, API key env var name, model ID(s), and enabled status. 2. Add the provider entry to `v2-config.template.json` under `provider`. + Explicit API-key providers use `source: custom`. For Z.ai, use + `https://api.z.ai/api/anthropic` and preserve the separate `zai: oauth` + account preference; BigModel uses `https://open.bigmodel.cn/api/anthropic`. 3. Add the `${API_KEY_VAR}` placeholder to `build/.env.example` (empty value). 4. Remind the user to fill the real value in `build/.env` (gitignored). 5. Validate JSON and run `install --plan`. diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-tool.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-tool.md index 02290c2..13f159b 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-tool.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/commands/nddev-add-tool.md @@ -9,6 +9,8 @@ Follow the `add-tool` skill exactly: 1. Ask the user for: the marketplace name, the plugin name, the tool name, the script language (bash/python/node), and a one-sentence description. 2. Create `plugins//tools//` with the executable script and a README. 3. Optionally create a companion skill (`skills//SKILL.md`) that teaches the agent when and how to invoke the tool. -4. If the tool needs secrets, add `${VAR}` to `build/.env.example` and read from `~/.zcode/.env` at runtime. +4. If the tool needs secrets, declare the key with an empty value in + `build/.env.example` and accept it through the active project's approved + process environment. Never `source`, `.`, or `eval` an env file. 5. Make the script executable (`chmod +x`). 6. Run `install --plan` to confirm nothing broke. diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-agent/SKILL.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-agent/SKILL.md index c622955..9ef8797 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-agent/SKILL.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-agent/SKILL.md @@ -75,5 +75,7 @@ The body IS the subagent's system prompt. Write it as a complete role definition - Validate: `cli-tools/scripts/install.sh install --marketplace --platform macos --plan`. - Validate the same marketplace with `--platform ubuntu --plan`. -- For a release behavior change, keep `VERSION`, `build/version.json`, and - `build/manifest.json` in sync and update `CHANGELOG.md`. +- Before a repository release, use one strict SemVer in `VERSION`, the + `build_version` fields in `build/version.json` and `build/manifest.json`, the + `nddev-builder` marketplace `core` entry, and the core plugin manifest; also + update `CHANGELOG.md`. diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-hook/SKILL.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-hook/SKILL.md index 758c6da..bcdfd89 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-hook/SKILL.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-hook/SKILL.md @@ -148,5 +148,7 @@ a command hook but 500ms (half a second) for a process hook. Default is 60000ms - Validate: `cli-tools/scripts/install.sh install --marketplace --platform macos --plan`. - Validate the same marketplace with `--platform ubuntu --plan`. -- For a release behavior change, keep `VERSION`, `build/version.json`, and - `build/manifest.json` in sync and update `CHANGELOG.md`. +- Before a repository release, use one strict SemVer in `VERSION`, the + `build_version` fields in `build/version.json` and `build/manifest.json`, the + `nddev-builder` marketplace `core` entry, and the core plugin manifest; also + update `CHANGELOG.md`. diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-marketplace/SKILL.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-marketplace/SKILL.md index 800b582..d89580b 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-marketplace/SKILL.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-marketplace/SKILL.md @@ -12,7 +12,7 @@ Scaffolds a new self-contained marketplace — a complete `~/.zcode` build sourc The installer's `validate_marketplace` requires these **5 files** to exist inside the marketplace directory, or it refuses to build: -``` +```text AGENTS.md ← system instructions → ~/.zcode/AGENTS.md marketplace.json ← root manifest (name, owner, plugins[]) cli-config.template.json ← → ~/.zcode/cli/config.json (rendered) @@ -23,10 +23,10 @@ inside the marketplace directory, or it refuses to build: In addition, conventionally create these (start empty with a `_comment` key — the installer merges them into `cli/config.json` if present): -``` +```text mcp.json ← MCP servers (merged into cli/config.json.mcp.servers) hooks.json ← lifecycle hooks (merged into cli/config.json.hooks.events) - skills/ commands/ agents/ plugins/ ← user-scope dirs (start empty, add .gitkeep) + skills/ commands/ agents/ plugins/ ← optional user-scope component dirs ``` ## Procedure @@ -35,7 +35,8 @@ the installer merges them into `cli/config.json` if present): `nddev-designer`). Lowercase, hyphens. 2. **Create the directory tree:** - ``` + + ```bash mkdir -p zcode_tools/marketplaces//{skills,commands,agents,plugins} ``` @@ -45,32 +46,40 @@ the installer merges them into `cli/config.json` if present): - `AGENTS.md` → set the `` marker and describe this setup. - `cli-config.template.json` → keep the default (plugins/hooks/mcp skeleton). - `v2-config.template.json` → set the provider definitions (default: Z.ai GLM-5.2). - - `v2-setting.template.json` → set preferences (locale, recentProjects templated - with `${HOME}`). + - `v2-setting.template.json` → set portable preferences; start + `recentProjects` as `[]` and let ZCode populate device-local paths. - `mcp.json`, `hooks.json` → start empty (with the `_comment` key). 4. **Validate the marketplace is self-contained** before committing: + ```bash cli-tools/scripts/install.sh install --marketplace --platform macos --plan ``` + This runs `validate_marketplace` and will error if a required file is missing. 5. **Verify it appears in the list:** + ```bash cli-tools/scripts/install.sh list ``` -6. **Record release behavior changes.** Keep `VERSION`, - `build/version.json`, and `build/manifest.json` in sync and update - `CHANGELOG.md` when the repository is being prepared for release. +6. **Record release behavior changes.** Before a repository release, use one + strict SemVer in `VERSION`, the `build_version` fields in + `build/version.json` and `build/manifest.json`, the `nddev-builder` + marketplace `core` entry, and the core plugin manifest; also update + `CHANGELOG.md`. ## Rules - Every marketplace is self-contained — it owns its AGENTS.md and all config templates, not shared at the `zcode_tools/` root. - The marketplace name must match its directory name. -- Start with empty `skills/`/`commands/`/`agents/`/`plugins/` (`.gitkeep`) and - fill them deliberately later. +- Empty `skills/`/`commands/`/`agents/`/`plugins/` are valid only for a + deliberately minimal profile whose `AGENTS.md` and marketplace description + explain why project-specific tooling comes from the active workspace. Do not + publish a future-work placeholder as an available setup. - English only for all content. -- `validate_marketplace` is the gatekeeper — if the `--plan` run passes, the - marketplace is correctly structured. +- `validate_marketplace` proves the minimum structure. Also inspect provider + semantics, portability, enabled plugins, secret boundaries, and the profile's + substantive operating instructions before treating it as release-ready. diff --git a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-mcp-server/SKILL.md b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-mcp-server/SKILL.md index 73c92a2..5fc76e0 100644 --- a/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-mcp-server/SKILL.md +++ b/zcode_tools/marketplaces/nddev-builder/plugins/core/skills/add-mcp-server/SKILL.md @@ -9,19 +9,19 @@ Registers a tool integration. **Two paths exist** — choose based on the trade- ## Path A (classic MCP) vs Path B (CLI + skill) — the decision -MCP servers load their full tool schema into the agent's context **permanently** -(every session). 2026 benchmarks show this costs **up to 10–35× more tokens** -than CLI tools for the same task (Arize eval: ~6× costlier; Scalekit: 10-32×; -Firecrawl: 4-32×). MCP schemas are also hard to extend and not composable -(results must pass through the context window). +MCP servers expose tool schemas to the agent's session context. The exact +context footprint depends on the client and server surface, and results also +pass through the model context. This standardized interface is useful, but a +large tool catalog can create a larger permanent context surface. -CLI tools + a skill (or README) cost **zero tokens until invoked**. The agent -writes loops, pipes, and scripts — composable, cheap, easy to modify. The -trade-off: CLI is less constrained (the agent can do anything the shell allows). +CLI tools plus a concise skill or README keep baseline routing metadata small; +the detailed skill body, command help, and tool output are loaded only when +needed. The agent can compose loops, pipes, and scripts, but the shell surface +is less constrained than MCP. | Dimension | MCP server | CLI + skill | -|---|---|---| -| Token cost | High (schema always loaded) | Zero until called | +| --- | --- | --- | +| Context footprint | Session tool schemas | Small metadata; detail on demand | | Composability | Low (results through context) | High (pipes, files, loops) | | Safety | More constrained | Less constrained | | Best for | Standardized discovery, cross-agent | Local dev, token efficiency | @@ -37,7 +37,7 @@ permission model. Register in the active marketplace's `mcp.json`: -``` +```text zcode_tools/marketplaces//mcp.json ``` @@ -47,6 +47,7 @@ time. ### Entry shapes stdio server (`command` is a **string**, NOT an array — OpenCode-style arrays crash): + ```json { "mcpServers": { @@ -62,6 +63,7 @@ stdio server (`command` is a **string**, NOT an array — OpenCode-style arrays ``` http server: + ```json { "mcpServers": { @@ -101,8 +103,9 @@ Add the matching `VAR=` key (empty) to `build/.env.example` (committed) and ## Path B: CLI + skill (the lean alternative) Instead of an MCP server, create a small CLI tool (a shell or node script) plus -a skill (or README) that documents how the agent calls it. The agent discovers -the tool by reading the skill on demand — zero context cost until used. +a skill (or README) that documents how the agent calls it. Compact metadata +supports routing; the detailed skill body and tool output are consumed on +demand. ### Layout @@ -112,7 +115,7 @@ so tools arrive automatically. At runtime, the agent references them via `${CLAUDE_PLUGIN_ROOT}/tools//