diff --git a/CHANGELOG.md b/CHANGELOG.md index af73494..4d1af86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [2.1.0] - 2026-07-10 + +### Added + +- Added `--setup ` as the canonical setup selector while preserving + `--marketplace ` as a backward-compatible native-format alias. +- Added stable `list --json` setup discovery and `status [--target] [--json]` + inspection for missing, unmanaged, legacy-managed, and setup-aware managed + installations. +- Added `BUILD-VERSION` schema 2 with a required `setup_id`, so installed and + backed-up trees retain the exact setup identity used to build them. + +### Changed + +- Upgraded the public product contract to version 3 and documented the setup + catalog, setup-selection aliases, machine-readable interfaces, and stamp + compatibility policy. +- Made staged-build verification fail closed unless the schema-2 stamp setup + identity matches the selected setup. Legacy schema 0 and 1 stamps remain + readable for safe recovery operations and report an unknown setup identity. +- Bumped the public build and `nddev-builder/core` component to 2.1.0. ZCode + application, CLI, runtime, and artifact identity pins are unchanged. + ## [2.0.2] - 2026-07-10 ### Security diff --git a/README.md b/README.md index 9192512..53f245b 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ `nddev-zcode-app` is a reusable build system and installer for complete, version-stamped ZCode setups. It recreates `~/.zcode` from a selected local -marketplace on macOS or Ubuntu, backs up the previous installation, and +setup catalog entry on macOS or Ubuntu, backs up the previous installation, and selectively restores runtime state so sessions and credentials survive setup changes. - **Author:** Danil Silantyev (github:rldyourmnd), CEO NDDev - **License:** AGPL-3.0-or-later -- **Build version:** 2.0.2 +- **Build version:** 2.1.0 - **Verified ZCode runtime:** app 3.3.3, CLI 0.15.0, model GLM-5.2 ## What this repository contains @@ -47,8 +47,11 @@ cli-tools/scripts/install.sh bootstrap --apply # Inspect and install a setup. 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 +cli-tools/scripts/install.sh list --json +cli-tools/scripts/install.sh install --setup nddev-builder --plan +cli-tools/scripts/install.sh install --setup nddev-builder --apply +cli-tools/scripts/install.sh status +cli-tools/scripts/install.sh status --json ``` Plan mode performs no writes and does not invoke a locally installed `zcode` @@ -77,8 +80,11 @@ 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. +The installer copies exactly one selected setup into the target ZCode home. +`--setup` is the public selector; `--marketplace` remains a compatibility alias +for the underlying ZCode-native storage format. Every new `BUILD-VERSION` +schema-2 stamp records the selected `setup_id`, and `status` validates and +reports it. Setup content is ordinary source and can be adapted independently. ## Backup and restore contract diff --git a/VERSION b/VERSION index e9307ca..7ec1d6d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.2 +2.1.0 diff --git a/build/manifest.json b/build/manifest.json index 5f3fb93..04adc1c 100644 --- a/build/manifest.json +++ b/build/manifest.json @@ -1,11 +1,11 @@ { "name": "nddev-zcode-app", - "build_version": "2.0.2", + "build_version": "2.1.0", "source_root": "zcode_tools/marketplaces/", - "installer": "cli-tools/scripts/install.sh --marketplace ", + "installer": "cli-tools/scripts/install.sh install --setup ", "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)", + "zcode_tools/marketplaces//": "each native marketplace is a self-contained ~/.zcode setup (AGENTS.md, config templates, mcp/hooks, skills/commands/agents, plugins)", + "cli-tools/": "setup manager: --setup selects one catalog entry and builds ~/.zcode from it (macOS + Ubuntu)", "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", @@ -33,12 +33,21 @@ }, "command_option_policy": { "bootstrap": ["--platform", "--apply", "--plan", "--dry-run"], - "install": ["--marketplace", "--target", "--platform", "--apply", "--plan", "--dry-run", "--adopt-unmanaged"], + "install": ["--setup", "--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"], + "list": ["-l", "--list", "--backups", "--json"], + "status": ["--target", "--json"], "invalid_combination": "unknown, command-inapplicable, and mutually exclusive mode options are rejected instead of ignored" }, + "setup_state_policy": { + "catalog_identity": "safe kebab-case directory id must equal marketplace.json:name", + "selection_option": "--setup ; --marketplace remains a backward-compatible alias", + "new_stamp_schema": 2, + "new_stamp_identity": "BUILD-VERSION.setup_id must equal the selected setup id", + "legacy_recovery": "schema 0 and 1 stamps remain readable for backup, restore, remove, and status; their setup identity is reported as unknown", + "machine_interfaces": ["list --json", "status --json"] + }, "runtime_probe_policy": { "resolution": "resolve and canonicalize one executable before invoking it", "timeout_seconds": 3, diff --git a/build/version.json b/build/version.json index 3d52cb9..0a08e6f 100644 --- a/build/version.json +++ b/build/version.json @@ -1,5 +1,5 @@ { - "build_version": "2.0.2", + "build_version": "2.1.0", "zcode_app_version": "3.3.3", "zcode_cli_version": "0.15.0", "zcode_runtime": "GLM-5.2", diff --git a/cli-tools/scripts/install.sh b/cli-tools/scripts/install.sh index 490ca0c..bc5c261 100755 --- a/cli-tools/scripts/install.sh +++ b/cli-tools/scripts/install.sh @@ -1,20 +1,21 @@ #!/usr/bin/env bash # # nddev-zcode-app installer — manages a complete, version-stamped ~/.zcode -# built from ONE selected marketplace. Supports bootstrap, install, update, -# switch, backup inspection, restore, and removal on macOS or Ubuntu. +# built from ONE selected setup. Supports bootstrap, install, update, switch, +# status, backup inspection, restore, and removal on macOS or Ubuntu. # # Usage: # cli-tools/scripts/install.sh [options] # # Commands: # bootstrap Download and install the pinned ZCode app and CLI. -# install (default) Build ~/.zcode from a marketplace (backup → build → restore). +# install (default) Build ~/.zcode from a setup (backup → build → restore). # remove Back up and delete the installed ~/.zcode. # restore Restore ~/.zcode from a numbered backup slot. -# list List available marketplaces or backups. +# list List available setups or backups. +# status Report the installed setup and version stamp. # -# Each marketplace is a self-contained setup (its own AGENTS.md, config +# Each native marketplace directory is a self-contained setup (its own AGENTS.md, config # templates, skills/commands/agents, and plugins). The installer selects one # and builds a clean ~/.zcode entirely from it. # @@ -49,24 +50,28 @@ SEEN_ADOPT=0 SEEN_RELOCATION=0 SEEN_BACKUPS=0 SEEN_LIST=0 +SEEN_JSON=0 +OUTPUT_JSON=0 usage() { cat <<'EOF' -Usage: cli-tools/scripts/install.sh [bootstrap|install|remove|restore|list] [options] +Usage: cli-tools/scripts/install.sh [bootstrap|install|remove|restore|list|status] [options] Commands: bootstrap Download and install the ZCode desktop app + CLI (from zero). - install (default) Build ~/.zcode from a marketplace. + install (default) Build ~/.zcode from a setup. remove Back up and delete the installed ~/.zcode. restore Restore ~/.zcode from a backup slot (0-9). - list List available marketplaces (and backups with --backups). + list List available setups (and backups with --backups). + status Show the installed setup and validated version stamp. Options (bootstrap): --platform macos|ubuntu Target platform (default: auto-detect from uname). --apply Execute the download + install (default is --plan). Options (install): - --marketplace Which marketplace/setup to build from (required for install). + --setup Which setup to build from (required for install). + --marketplace Backward-compatible alias for --setup. --target Install directory (default: ~/.zcode, or ZCODE_TARGET in .env). --platform macos|ubuntu Target platform (default: auto-detect from uname). --apply Execute (default is --plan / dry-run). @@ -86,7 +91,11 @@ Options (restore): explicitly selected --target. --apply Execute the restore (default is --plan). -Target resolution (install/remove/restore): +Options (list/status): + --json Emit stable machine-readable JSON. + --target Status target (default: ~/.zcode, or ZCODE_TARGET in .env). + +Target resolution (install/remove/restore/status): --target flag > ZCODE_TARGET (build/.env) > ~/.zcode Backup convention: @@ -94,47 +103,121 @@ Backup convention: EOF } -list_marketplaces() { - local root +list_setups() { + local output=$1 root root="$(nddev::repo_root)/zcode_tools/marketplaces" - nddev::section "Available marketplaces" - if [ ! -d "$root" ]; then - nddev::log "info" "no marketplaces directory: $root" - return + if [ "$output" = "human" ]; then + nddev::section "Available setups" fi - local d - for d in "$root"/*/; do - [ -d "$d" ] && [ ! -L "$d" ] || continue - local name desc - name="$(basename "$d")" - printf '%s\n' "$name" | grep -qE '^[a-z0-9][a-z0-9-]*$' || continue - if ! desc="$(python3 -I - "${d}marketplace.json" <<'PY' + python3 -I - "$root" "$output" <<'PY' import json import os +import re 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) +root, output = sys.argv[1:] +if output not in {"human", "json"}: + raise SystemExit("unsupported setup-list output mode") +if not os.path.lexists(root): + setups = [] +else: + root_metadata = os.lstat(root) + if stat.S_ISLNK(root_metadata.st_mode) or not stat.S_ISDIR(root_metadata.st_mode): + raise SystemExit("setup catalog root must be a real directory") + setups = [] + for entry in sorted(os.scandir(root), key=lambda candidate: candidate.name): + if re.fullmatch(r"[a-z0-9][a-z0-9-]*", entry.name) is None: + continue + if not entry.is_dir(follow_symlinks=False): + raise SystemExit(f"unsafe setup catalog entry: {entry.name}") + manifest_path = os.path.join(entry.path, "marketplace.json") + try: + manifest_metadata = os.lstat(manifest_path) + if stat.S_ISLNK(manifest_metadata.st_mode) or not stat.S_ISREG(manifest_metadata.st_mode): + raise SystemExit(f"unsafe setup manifest: {entry.name}") + with open(manifest_path, encoding="utf-8") as stream: + manifest = json.load(stream) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SystemExit(f"cannot read setup manifest: {entry.name}") from exc + if not isinstance(manifest, dict) or manifest.get("name") != entry.name: + raise SystemExit(f"setup manifest identity mismatch: {entry.name}") + description = manifest.get("description", "") + plugins = manifest.get("plugins") + if ( + not isinstance(description, str) + or any(ord(char) < 32 or ord(char) == 127 for char in description) + or not isinstance(plugins, list) + ): + raise SystemExit(f"invalid setup manifest summary: {entry.name}") + setups.append( + {"id": entry.name, "description": description, "plugin_count": len(plugins)} + ) +if output == "json": + print(json.dumps({"schema_version": 1, "setups": setups}, separators=(",", ":"))) +elif not setups: + print(" no setups found") +else: + for setup in setups: + print(f" {setup['id']:<24} {setup['description']}") PY -)"; then - desc="" +} + +show_status() { + local target=$1 output=$2 metadata setup_label + if [ ! -e "$target" ] && [ ! -L "$target" ]; then + if [ "$output" = "json" ]; then + printf '{"schema_version":1,"state":"missing"}\n' + else + nddev::section "Installation status" + printf ' state: missing\n' fi - printf ' %-24s %s\n' "$name" "$desc" - done + return 0 + fi + if [ ! -e "$target/BUILD-VERSION" ] && [ ! -L "$target/BUILD-VERSION" ]; then + if [ "$output" = "json" ]; then + printf '{"schema_version":1,"state":"unmanaged"}\n' + else + nddev::section "Installation status" + printf ' state: unmanaged\n' + fi + return 0 + fi + if [ -L "$target/BUILD-VERSION" ] || [ ! -f "$target/BUILD-VERSION" ]; then + nddev::log "error" "BUILD-VERSION must be a regular non-symlink file" + return 1 + fi + metadata="$(nddev::stamp_metadata "$target" status-json)" || return 1 + if [ "$output" = "json" ]; then + python3 -I - "$metadata" <<'PY' +import json +import sys + +stamp = json.loads(sys.argv[1]) +print(json.dumps({"schema_version": 1, "state": "managed", **stamp}, separators=(",", ":"))) +PY + return 0 + fi + setup_label="$(nddev::stamp_setup_id "$target")" || return 1 + [ -n "$setup_label" ] || setup_label="unknown (legacy stamp)" + nddev::section "Installation status" + python3 -I - "$metadata" "$setup_label" <<'PY' +import json +import sys + +stamp = json.loads(sys.argv[1]) +print(" state: managed") +print(f" setup: {sys.argv[2]}") +print(f" build: {stamp['build_version']}") +print(f" platform: {stamp['platform']}") +print(f" installed: {stamp['installed_at']}") +PY } # ─── Parse command (first positional, if present) ──────────────────────── if [ "$#" -gt 0 ]; then case "$1" in - bootstrap|install|remove|restore|list) + bootstrap|install|remove|restore|list|status) COMMAND="$1" COMMAND_EXPLICIT=1 shift @@ -151,7 +234,7 @@ fi # ─── Parse flags ───────────────────────────────────────────────────────── while [ "$#" -gt 0 ]; do case "$1" in - --marketplace) + --setup | --marketplace) nddev::require_option_once "$SEEN_MARKETPLACE" "$1" || exit 2 nddev::require_option_value "$1" "${2-}" || exit 2 MARKETPLACE="$2" @@ -215,6 +298,12 @@ while [ "$#" -gt 0 ]; do SEEN_BACKUPS=1 shift ;; + --json) + nddev::require_option_once "$SEEN_JSON" "$1" || exit 2 + OUTPUT_JSON=1 + SEEN_JSON=1 + shift + ;; -l | --list) nddev::require_option_once "$SEEN_LIST" "$1" || exit 2 if [ "$COMMAND_EXPLICIT" -eq 1 ] && [ "$COMMAND" != "list" ]; then @@ -255,37 +344,41 @@ fi case "$COMMAND" in bootstrap) - nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_MARKETPLACE" --setup/--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 + nddev::reject_seen_option "$SEEN_JSON" --json ;; 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 + nddev::reject_seen_option "$SEEN_JSON" --json ;; remove) - nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_MARKETPLACE" --setup/--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 + nddev::reject_seen_option "$SEEN_JSON" --json ;; restore) - nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_MARKETPLACE" --setup/--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 + nddev::reject_seen_option "$SEEN_JSON" --json ;; list) - nddev::reject_seen_option "$SEEN_MARKETPLACE" --marketplace + nddev::reject_seen_option "$SEEN_MARKETPLACE" --setup/--marketplace nddev::reject_seen_option "$SEEN_TARGET" --target nddev::reject_seen_option "$SEEN_PLATFORM" --platform nddev::reject_seen_option "$SEEN_APPLY" --apply @@ -295,12 +388,27 @@ case "$COMMAND" in nddev::reject_seen_option "$SEEN_ADOPT" --adopt-unmanaged nddev::reject_seen_option "$SEEN_RELOCATION" --allow-target-relocation ;; + status) + nddev::reject_seen_option "$SEEN_MARKETPLACE" --setup/--marketplace + 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 + nddev::reject_seen_option "$SEEN_BACKUPS" --backups + ;; 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 + if [ "$OUTPUT_JSON" -eq 1 ]; then + nddev::log "error" "--json is not valid with list --backups" + exit 2 + fi COMMAND="list-backups" fi @@ -326,11 +434,11 @@ fi case "$COMMAND" in install) if [ -z "$MARKETPLACE" ]; then - nddev::log "error" "install requires --marketplace (use 'list' to see options)" + nddev::log "error" "install requires --setup (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" + nddev::log "error" "invalid setup id" exit 2 fi case "$PLATFORM" in auto | macos | ubuntu) ;; *) nddev::log "error" "unsupported platform (expected macos|ubuntu)"; exit 2 ;; esac @@ -359,12 +467,20 @@ if [ "$COMMAND" = "bootstrap" ]; then exec "$BOOTSTRAP" "${bootstrap_args[@]}" fi -# Every remaining command uses isolated Python helpers. Plain marketplace +# Every remaining command uses isolated Python helpers. Plain setup # 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 [ "$OUTPUT_JSON" -eq 1 ]; then + nddev::require_cmd python3 required >/dev/null || exit 1 +else + nddev::require_cmd python3 required || exit 1 +fi if [ "$COMMAND" = "list" ]; then - list_marketplaces + if [ "$OUTPUT_JSON" -eq 1 ]; then + list_setups json + else + list_setups human + fi exit 0 fi nddev::load_env paths-only || exit 1 @@ -381,6 +497,18 @@ elif [ -n "${ZCODE_TARGET:-}" ]; then export NDDEV_TARGET="$ZCODE_TARGET" fi +# ─── Handle read-only status command ───────────────────────────────────── +if [ "$COMMAND" = "status" ]; then + status_target="${NDDEV_TARGET:-$HOME/.zcode}" + status_target="$(nddev::validate_directory_endpoint "install target" "$status_target")" || exit 2 + if [ "$OUTPUT_JSON" -eq 1 ]; then + show_status "$status_target" json + else + show_status "$status_target" human + fi + exit $? +fi + # ─── Handle 'list-backups' command ─────────────────────────────────────── if [ "$COMMAND" = "list-backups" ]; then backups_dir="${NDDEV_BACKUPS_DIR:-${ZCODE_BACKUPS_DIR:-$HOME/.zcode-backups}}" diff --git a/cli-tools/scripts/lib/build.sh b/cli-tools/scripts/lib/build.sh index 955e2ee..b5989bb 100644 --- a/cli-tools/scripts/lib/build.sh +++ b/cli-tools/scripts/lib/build.sh @@ -1216,7 +1216,7 @@ nddev::verify_managed_tree() { } nddev::verify_build() { - local target=$1 mp_name errors=0 + local target=$1 mp_name stamped_setup errors=0 mp_name="$(basename "$SOURCE_DIR")" nddev::section "Verify staged build" if [ "${NDDEV_DRY_RUN:-1}" -eq 1 ]; then @@ -1224,6 +1224,13 @@ nddev::verify_build() { return 0 fi nddev::verify_managed_tree "$target" || errors=$((errors + 1)) + if ! stamped_setup="$(nddev::stamp_setup_id "$target")"; then + nddev::log "missing" "BUILD-VERSION setup identity is invalid" + errors=$((errors + 1)) + elif [ "$stamped_setup" != "$mp_name" ]; then + nddev::log "missing" "BUILD-VERSION setup identity does not match selected setup" + errors=$((errors + 1)) + fi if [ ! -f "$target/AGENTS.md" ]; then nddev::log "missing" "AGENTS.md not found" errors=$((errors + 1)) @@ -1258,7 +1265,7 @@ PY # --- Public orchestration -------------------------------------------------- nddev::install_sequence() { - local platform=$1 current_version had_target=0 adoption_mode=managed + local platform=$1 current_version setup_id had_target=0 adoption_mode=managed nddev::prepare_paths || return nddev::log "info" "target: $ZCODE_HOME" nddev::begin_transaction || return @@ -1287,7 +1294,8 @@ nddev::install_sequence() { 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 + setup_id="$(basename "$SOURCE_DIR")" + nddev::write_version_stamp "$NDDEV_STAGE_PATH" "$platform" "$setup_id" || return if [ "$had_target" -eq 1 ]; then nddev::restore_runtime "$ZCODE_HOME" "$NDDEV_STAGE_PATH" "$adoption_mode" || return fi diff --git a/cli-tools/scripts/lib/common.sh b/cli-tools/scripts/lib/common.sh index 09276b1..43d910d 100644 --- a/cli-tools/scripts/lib/common.sh +++ b/cli-tools/scripts/lib/common.sh @@ -550,18 +550,19 @@ raise SystemExit(0 if re.fullmatch(pattern, value) else 1) PY } -# Validate a managed BUILD-VERSION stamp and print its sanitized build version. +# Validate a managed BUILD-VERSION stamp and print one sanitized representation. # 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' +# destructive remove or restore. Schema 0/1 remains readable for recovery; +# schema 2 binds every newly installed tree to its selected setup. +nddev::stamp_metadata() { + local tree=$1 output=${2:-version} + python3 -I - "$tree/BUILD-VERSION" "$NDDEV_SEMVER_PATTERN" "$output" <<'PY' import datetime as dt import json import re import sys -path, semver_pattern = sys.argv[1:] +path, semver_pattern, output = sys.argv[1:] semver = re.compile(semver_pattern) try: with open(path, encoding="utf-8") as stream: @@ -571,10 +572,24 @@ except (OSError, json.JSONDecodeError) as 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 1 is the legacy explicit stamp. New stamps are schema 2; every other +# version fails closed. schema = data.get("schema", 0) -if isinstance(schema, bool) or schema not in {0, 1}: +if isinstance(schema, bool) or schema not in {0, 1, 2}: raise SystemExit("unsupported BUILD-VERSION schema") +base_keys = { + "build_version", + "zcode_app_version", + "zcode_cli_version", + "zcode_runtime", + "platform", + "installed_at", +} +expected_keys = base_keys if schema == 0 else base_keys | {"schema"} +if schema == 2: + expected_keys |= {"setup_id"} +if set(data) != expected_keys: + raise SystemExit("BUILD-VERSION keys do not match its 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): @@ -593,10 +608,47 @@ 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"]) +setup_id = data.get("setup_id") +if schema == 2 and ( + not isinstance(setup_id, str) + or re.fullmatch(r"[a-z0-9][a-z0-9-]*", setup_id) is None +): + raise SystemExit("BUILD-VERSION.setup_id must be kebab-case") + +if output == "version": + print(data["build_version"]) +elif output == "setup-id": + print(setup_id or "") +elif output == "status-json": + print( + json.dumps( + { + "stamp_schema": schema, + "setup_id": setup_id, + "build_version": data["build_version"], + "zcode_app_version": data["zcode_app_version"], + "zcode_cli_version": data["zcode_cli_version"], + "zcode_runtime": data["zcode_runtime"], + "platform": data["platform"], + "installed_at": installed_at, + }, + separators=(",", ":"), + sort_keys=True, + ) + ) +else: + raise SystemExit("unsupported BUILD-VERSION output mode") PY } +nddev::stamp_version() { + nddev::stamp_metadata "$1" version +} + +nddev::stamp_setup_id() { + nddev::stamp_metadata "$1" setup-id +} + nddev::current_version() { local zcode_home="${NDDEV_TARGET:-${ZCODE_HOME:-$HOME/.zcode}}" if [ ! -e "$zcode_home" ]; then diff --git a/cli-tools/scripts/lib/version.sh b/cli-tools/scripts/lib/version.sh index b9fb61f..1b84414 100644 --- a/cli-tools/scripts/lib/version.sh +++ b/cli-tools/scripts/lib/version.sh @@ -281,9 +281,13 @@ nddev::check_runtime_version() { } nddev::write_version_stamp() { - local target=$1 platform=$2 + local target=$1 platform=$2 setup_id=$3 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 + if ! printf '%s\n' "$setup_id" | grep -qE '^[a-z0-9][a-z0-9-]*$'; then + nddev::log "error" "invalid setup id: $setup_id" + return 2 + fi build_version="$(nddev::build_version)" || return 1 zcode_runtime="$(nddev::zcode_runtime)" || return 1 @@ -295,15 +299,16 @@ nddev::write_version_stamp() { return 0 fi - python3 -I - "$target/BUILD-VERSION" "$build_version" "$app_ver" "$cli_ver" "$zcode_runtime" "$platform" "$installed_at" <<'PY' || return 1 + python3 -I - "$target/BUILD-VERSION" "$build_version" "$app_ver" "$cli_ver" "$zcode_runtime" "$platform" "$setup_id" "$installed_at" <<'PY' || return 1 import json import os import sys import tempfile -path, build, app, cli, runtime, platform, installed = sys.argv[1:] +path, build, app, cli, runtime, platform, setup_id, installed = sys.argv[1:] payload = { - "schema": 1, + "schema": 2, + "setup_id": setup_id, "build_version": build, "zcode_app_version": app, "zcode_cli_version": cli, @@ -329,5 +334,5 @@ except BaseException: PY chmod 600 "$target/BUILD-VERSION" || return 1 nddev::stamp_version "$target" >/dev/null || return 1 - nddev::log "ok" "wrote BUILD-VERSION ($build_version, $platform, zcode $app_ver)" + nddev::log "ok" "wrote BUILD-VERSION ($build_version, setup $setup_id, $platform, zcode $app_ver)" } diff --git a/config/nddev-contract.json b/config/nddev-contract.json index 2aae322..b360a96 100644 --- a/config/nddev-contract.json +++ b/config/nddev-contract.json @@ -1,5 +1,5 @@ { - "contract_version": 2, + "contract_version": 3, "product_name": "nddev-zcode-app", "display_name": "NDDev ZCode App", "description": "Build system and installer that recreates a complete, version-stamped ZCode home from a selected portable setup on macOS or Ubuntu.", @@ -15,7 +15,18 @@ "version_ref": "build/version.json", "manifest_ref": "build/manifest.json", "source_root": "zcode_tools/marketplaces/", - "installer_entry": "cli-tools/scripts/install.sh install --marketplace ", + "installer_entry": "cli-tools/scripts/install.sh install --setup ", + "setup_system": { + "catalog_root": "zcode_tools/marketplaces/", + "identity_source": "/marketplace.json:name", + "selection_option": "--setup ", + "compatibility_alias": "--marketplace ", + "list_command": "cli-tools/scripts/install.sh list [--json]", + "status_command": "cli-tools/scripts/install.sh status [--target ] [--json]", + "stamp_schema": 2, + "stamp_setup_key": "setup_id", + "readable_legacy_stamp_schemas": [0, 1] + }, "native_format": { "marketplace_root": "zcode_tools/marketplaces//marketplace.json", "plugin_manifest": "plugins//.zcode-plugin/plugin.json", diff --git a/docs/architecture.md b/docs/architecture.md index c1519d5..531d3ad 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,15 +8,17 @@ backup and restore operations. ## Implementation layers ```text -zcode_tools/ SOURCE: self-contained marketplace setups -cli-tools/ INSTALLER: lifecycle and rendering for macOS and Ubuntu +zcode_tools/ SOURCE: self-contained setup catalog in ZCode-native marketplaces +cli-tools/ SETUP MANAGER: discovery, status, lifecycle, and rendering build/ CONTRACT: versions, artifact integrity, manifest, secret template ``` ### Marketplace sources Each `zcode_tools/marketplaces//` directory is a complete setup. The -installer selects exactly one marketplace and builds the target from it. +installer selects exactly one setup and builds the target from its native +marketplace representation. `--setup` is canonical and `--marketplace` is a +backward-compatible alias. | Marketplace source | Installed target | Treatment | | --- | --- | --- | @@ -72,7 +74,8 @@ shared libraries and execute the same lifecycle: 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`, +4. copy source, structurally render JSON and MCP inputs, write a schema-2 + `BUILD-VERSION` bound to the selected `setup_id`, 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 @@ -99,7 +102,7 @@ Shared implementation: private permissions, safe dry-run operations, and structured template rendering. - `lib/version.sh` owns the public build/runtime version contract and installed - stamp. + stamp, including setup identity and legacy schema compatibility. - `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. @@ -162,7 +165,9 @@ 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 public product contract is `config/nddev-contract.json` version 3. It +defines setup discovery, selection, status, stamp identity, and legacy recovery +compatibility. It also 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 diff --git a/docs/install.md b/docs/install.md index ca17891..d9e6a0a 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,7 +6,7 @@ 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, +2. **`install`** — builds a clean `~/.zcode` from a setup (config, plugins, skills). Backs up the current one first. Both work on macOS (desktop) and Ubuntu (desktop/server). @@ -40,10 +40,10 @@ $EDITOR build/.env cli-tools/scripts/install.sh bootstrap --plan # dry-run first cli-tools/scripts/install.sh bootstrap --apply -# 4. Configure ~/.zcode from a marketplace: +# 4. Configure ~/.zcode from a setup: 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 +cli-tools/scripts/install.sh install --setup nddev-builder --plan +cli-tools/scripts/install.sh install --setup nddev-builder --apply ``` After step 3, ZCode is installed and the `zcode` command is on PATH. After @@ -105,22 +105,27 @@ root can be changed with `NDDEV_APPLICATIONS_DIR` for isolated environments. ## Usage ```bash -# List available setups (marketplaces): +# List available setups for people or automation: cli-tools/scripts/install.sh list +cli-tools/scripts/install.sh list --json # Install — plan (dry-run) first, then apply: -cli-tools/scripts/install.sh install --marketplace nddev-builder --plan -cli-tools/scripts/install.sh install --marketplace nddev-builder --apply +cli-tools/scripts/install.sh install --setup nddev-builder --plan +cli-tools/scripts/install.sh install --setup nddev-builder --apply + +# Inspect the selected setup and validated installed-state stamp: +cli-tools/scripts/install.sh status +cli-tools/scripts/install.sh status --json # 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 \ +cli-tools/scripts/install.sh install --setup nddev-builder \ --target "$HOME/.zcode" --adopt-unmanaged --plan -cli-tools/scripts/install.sh install --marketplace nddev-builder \ +cli-tools/scripts/install.sh install --setup 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). +# Update — re-run install with the same setup (old ~/.zcode is backed up). +# Switch — install a different setup (the old setup is backed up). # Remove — back up and delete the install: cli-tools/scripts/install.sh remove --apply @@ -131,12 +136,12 @@ 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 "$HOME/.zcode-work" --apply + --setup 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): cli-tools/scripts/install.sh install \ - --marketplace nddev-builder --platform macos --apply + --setup nddev-builder --platform macos --apply ``` ### Commands @@ -144,10 +149,11 @@ cli-tools/scripts/install.sh install \ | Command | What it does | | --- | --- | | `bootstrap` | Install the pinned ZCode app and CLI; defaults to plan mode. | -| `install` | Back up, build from one marketplace, and restore runtime state. | +| `install` | Back up, build from one setup, and restore runtime state. | | `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. | +| `list` | Show setups; add `--json` for automation or `--backups` for backup slots. | +| `status` | Validate and report missing, unmanaged, legacy-managed, or setup-aware managed state. | ### Command option matrix @@ -155,24 +161,25 @@ 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 | +| Option | bootstrap | install | remove | restore | list | status | +| --- | --- | --- | --- | --- | --- | --- | +| `--setup` (`--marketplace` alias) | — | yes | — | — | — | — | +| `--target` | — | yes | 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 | — | +| `--json` | — | — | — | — | yes (setups only) | 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, or restore target is resolved in this order: +The install, remove, restore, or status target is resolved in this order: 1. `--target ` flag (highest precedence) 2. `ZCODE_TARGET` in `build/.env` @@ -197,9 +204,9 @@ Every transition backs up the current install first into a rotating pool of **10 slots** (`0--old.zcode` … `9--old.zcode`), so the total never grows beyond 10 directories: -- **Install** — fresh build from a marketplace. -- **Update** — re-run `install` with the same marketplace (source changed). -- **Switch** — `install` with a different `--marketplace`. +- **Install** — fresh build from a setup. +- **Update** — re-run `install` with the same setup (source changed). +- **Switch** — `install` with a different `--setup`. - **Remove** — `remove` backs up and deletes. Slot selection: the lowest free slot (0–9). When all 10 are full, the **oldest** @@ -230,15 +237,17 @@ original canonical target. 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. +3. **Version stamp** — schema-2 `BUILD-VERSION` records the selected `setup_id`, + build version, ZCode runtime baseline, platform, and timestamp. Legacy + schema 0/1 stamps remain readable for recovery, but their setup identity is + reported as unknown. 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** — managed stamp and JSON schemas, marketplace presence, +5. **Verify** — managed stamp and JSON schemas, exact stamp/setup identity, marketplace presence, unresolved active config/setting/provider/MCP/hook placeholders in keys or values, symlink/special-file/hardlink absence, and private permissions are checked; diff --git a/zcode_tools/marketplaces/nddev-builder/marketplace.json b/zcode_tools/marketplaces/nddev-builder/marketplace.json index 6404120..3d0c05f 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.2", + "version": "2.1.0", "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 bf15e02..b04317c 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.2", + "version": "2.1.0", "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",