feat(core): three-layer DAG, unified component arch, commands-registry cache, Nushell 0.112.2 migration

- DAG architecture: `dag show/validate/export` (nulib/main_provisioning/dag.nu),
    config loader (lib_provisioning/config/loader/dag.nu), taskserv dag-executor.
    Backed by schemas/lib/dag/*.ncl; orchestrator emits NATS events via
    WorkspaceComposition::into_workflow. See ADR-020, ADR-021.
  - Unified Component Architecture: components/mod.nu, main_provisioning/
    {components,workflow,extensions,ontoref-queries}.nu. Full workflow engine with
    topological sort and NATS subject emission. Blocks A-H complete (libre-daoshi).
  - Commands-registry: nulib/commands-registry.ncl (Nickel source, 314 lines) +
    JSON cache at ~/.cache/provisioning/commands-registry.json rebuilt on source
    change. cli/provisioning fast-path alias expansion avoids cold Nu startup.
    ADDING_COMMANDS.md documents new-command workflow.
  - Platform service manager: service-manager.nu (+573), startup.nu (+611),
    service-check.nu (+255); autostart/bootstrap/health/target refactored.
  - Nushell 0.112.2 migration: removed all try/catch and bash redirections;
    external commands prefixed with ^; type signatures enforced. Driven by
    scripts/refactor-try-catch{,-simplified}.nu.
  - TTY stack: removed shlib/*-tty.sh; replaced by cli/tty-dispatch.sh,
    tty-filter.sh, tty-commands.conf.
  - New domain modules: images/ (golden image lifecycle), workspace/{state,sync}.nu,
    main_provisioning/{bootstrap,cluster-deploy,fip,state}.nu, commands/{state,
    build,integrations/auth,utilities/alias}.nu, platform.nu expanded (+874).
  - Config loader overhaul: loader/core.nu slimmed (-759), cache/core.nu
    refactored (-454), removed legacy loaders/file_loader.nu (-330).
  - Thirteen new provisioning-<domain>.nu top-level modules for bash dispatcher.
  - Tests: test_workspace_state.nu (+351); updates to test_oci_registry,
    test_services.
  - README + CHANGELOG updated.
This commit is contained in:
Jesús Pérez 2026-04-17 04:27:33 +01:00
parent adb28be45a
commit 894046ef5a
Signed by: jesus
GPG key ID: 9F243E355E0BC939
245 changed files with 22277 additions and 6121 deletions

View file

@ -0,0 +1,16 @@
{
"id": "5246326f-910e-4f2d-aef2-df29d0cbeeca",
"name": "execute_servers_script_",
"command": "bash",
"args": [
"-c",
"base64 -d < /tmp/orchestrator_script_5246326f-910e-4f2d-aef2-df29d0cbeeca.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x"
],
"dependencies": [],
"status": "Failed",
"created_at": "2026-02-17T00:50:47.638979Z",
"started_at": null,
"completed_at": "2026-02-17T00:50:49.815378Z",
"output": null,
"error": "Command execution failed: === Checking prerequisites ===\n✓ HCLOUD_TOKEN set\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key 'htz_ops' already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nState saved to: /tmp/.provisioning-state.json\nEnvironment variables exported to: /tmp/.env\n=== Checking prerequisites ===\n✓ Prerequisites satisfied\n\n=== Managing Network ===\n✓ Network config validated: 10.0.0.0/16 with subnet 10.0.0.0/22 in zone eu-central\nChecking if network 'librecloud-private' exists...\nCreating network 'librecloud-private' with IP range 10.0.0.0/16 (with protection enabled)...\n✓ Network 'librecloud-private' created with ID: 11943075\nCreating subnet with IP range 10.0.0.0/22 in network 11943075...\nERROR: Failed to create subnet\nResponse: hcloud: unknown shorthand flag: 'o' in -o\n"
}

View file

@ -0,0 +1,16 @@
{
"id": "881402e9-8851-4c3e-a988-5cf758d62803",
"name": "execute_servers_script_",
"command": "bash",
"args": [
"-c",
"base64 -d < /tmp/orchestrator_script_881402e9-8851-4c3e-a988-5cf758d62803.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x"
],
"dependencies": [],
"status": "Failed",
"created_at": "2026-02-17T00:13:37.519543Z",
"started_at": null,
"completed_at": "2026-02-17T00:13:39.388566Z",
"output": null,
"error": "Command execution failed: === Checking prerequisites ===\n✓ HCLOUD_TOKEN set\n\n=== Managing SSH Keys ===\n✓ SSH public key found: /Users/jesusperezlorenzo/.ssh/htz_ops.pub\nChecking if SSH key 'htz_ops' exists in Hetzner...\n✓ SSH key 'htz_ops' already exists with ID: 106168627\n\n=== SSH Key Management Complete ===\nSSH_KEY_ID: 106168627\nState saved to: /tmp/.provisioning-state.json\nEnvironment variables exported to: /tmp/.env\n=== Checking prerequisites ===\n✓ Prerequisites satisfied\n\n=== Managing Network ===\n✓ Network config validated: 10.0.0.0/16 with subnet 10.0.0.0/22 in zone eu-central\nChecking if network 'librecloud-private' exists...\nCreating network 'librecloud-private' with IP range 10.0.0.0/16 (with protection enabled)...\n✓ Network 'librecloud-private' created with ID: 11943026\nCreating subnet with IP range 10.0.0.0/22 in network 11943026...\nERROR: Failed to create subnet\nResponse: hcloud: unknown shorthand flag: 'o' in -o\n"
}

View file

@ -0,0 +1,16 @@
{
"id": "d2643c9b-dd6e-42f9-9d72-0a767b5c308c",
"name": "execute_servers_script_",
"command": "bash",
"args": [
"-c",
"base64 -d < /tmp/orchestrator_script_d2643c9b-dd6e-42f9-9d72-0a767b5c308c.tar.gz.b64 | gunzip | tar -xOf - script.sh | bash +x"
],
"dependencies": [],
"status": "Failed",
"created_at": "2026-02-16T23:52:45.294780Z",
"started_at": null,
"completed_at": "2026-02-16T23:52:47.796824Z",
"output": null,
"error": "Command execution failed: hcloud: invalid input in field 'networks' (invalid_input, f966df9ad630cc87f7f495a9502858b1)\n- networks: networks must have at least one subnetwork\n"
}

View file

@ -134,8 +134,10 @@ repos:
# exclude: ^\.woodpecker/
- id: end-of-file-fixer
exclude: ^(\.coder/|\.wrks/|\.claude/)
- id: trailing-whitespace
exclude: \.md$
exclude: \.md$|^(\.coder/|\.wrks/|\.claude/)
- id: mixed-line-ending
exclude: ^(\.coder/|\.wrks/|\.claude/)

View file

@ -1,6 +1,6 @@
# Provisioning Core - Changelog
**Date**: 2026-01-14
**Date**: 2026-04-17
**Repository**: provisioning/core
**Status**: Nickel IaC (PRIMARY)
@ -8,12 +8,154 @@
## 📋 Summary
Core system with Nickel as primary IaC: Terminology migration from cluster to taskserv throughout codebase,
Nushell library refactoring for improved ANSI output formatting, and enhanced handler modules for infrastructure operations.
Major refactor: three-layer DAG architecture with workspace composition, Unified
Component Architecture (components + workflows + capabilities), Nickel-backed
commands-registry with JSON cache for fast CLI startup, consolidated platform
service manager, and completed Nushell 0.110/0.112 migration (no try/catch, no
bash redirections). TTY stack moved from `shlib/` into `cli/tty-*`. Numerous new
domain modules: `dag`, `components`, `workflow` engine, `images` lifecycle,
workspace state/sync, ontoref queries, FIP handler.
---
## 🔄 Latest Release (2026-01-14)
## 🔄 Latest Release (2026-04-17)
### Three-Layer DAG Architecture
**Scope**: Workspace composition as a DAG with formula_id::task_name namespacing,
health gates, conditions, and NATS subject emission.
**New files**:
- `nulib/main_provisioning/dag.nu``dag show/validate/export` (DOT/JSON/Mermaid)
- `nulib/lib_provisioning/config/loader/dag.nu` — DAG config loader
- `nulib/taskservs/dag-executor.nu` — taskserv-level DAG execution helper
**Related**: ADR-020 (extension capability declarations), ADR-021 (workspace
composition DAG). Orchestrator consumes composition via
`WorkspaceComposition::into_workflow` and emits NATS events.
### Unified Component Architecture
**Scope**: Components + workflows + capabilities as first-class citizens
(libre-daoshi plan, blocks A-H complete).
**New files**:
- `nulib/components/mod.nu` — component dispatch module
- `nulib/main_provisioning/components.nu``validate capabilities/components`,
`component list/info`
- `nulib/main_provisioning/workflow.nu` — full workflow engine: run/list/status/
validate, topological sort, NATS event emission (+605 lines)
- `nulib/main_provisioning/extensions.nu``extensions capabilities/graph`
- `nulib/main_provisioning/ontoref-queries.nu` — on+re-aware CLI queries
(describe component/databases/namespace/storage/workflow)
### Commands-Registry & Fast-Path Dispatch
**Scope**: Eliminate Nu startup cost on every `prvng` invocation.
**New files**:
- `nulib/commands-registry.ncl` — Nickel command catalog (314 lines)
- `nulib/lib_provisioning/utils/command-registry.nu` — registry accessor
- `nulib/scripts/validate-command.nu` — cache-aware command validator
**Behavior**: `cli/provisioning` reads the JSON cache at
`~/.cache/provisioning/commands-registry.json`, rebuilt automatically via
`nickel export` when the `.ncl` source is newer. Single-char aliases
(`s`, `t`, `c`, `e`, `w`, `j`, `b`, `o`, `a`) are expanded in bash before
dispatch. `nulib/main_provisioning/ADDING_COMMANDS.md` documents the four-step
procedure for new commands.
### Platform Service Manager
**New files**:
- `nulib/lib_provisioning/platform/service-manager.nu` (+573 lines)
- `nulib/lib_provisioning/platform/startup.nu` (+611 lines)
- `nulib/lib_provisioning/utils/service-check.nu` (+255 lines)
**Refactored**: `platform/autostart.nu`, `platform/bootstrap.nu`,
`platform/health.nu`, `platform/target.nu` — unified lifecycle, health probes,
and autostart logic.
### Nushell 0.112.2 Migration
**Scope**: Project-wide refactor driven by `scripts/refactor-try-catch.nu` and
`scripts/refactor-try-catch-simplified.nu` to reach Nushell 0.112.2 compliance.
**Enforced**:
- No `try/catch` — all use `do { } | complete`
- No bash redirections (`2>&1`, `2>/dev/null`)
- External commands prefixed with `^`
- Parenthesized pipelines in `if`
- Type signatures: `def f [x: string]: nothing -> record { }`
### TTY Stack Replacement
**Removed**: `shlib/README.md`, `shlib/auth-login-tty.sh`,
`shlib/mfa-enroll-tty.sh`, `shlib/setup-wizard-tty.sh`.
**Replaced by**:
- `cli/tty-dispatch.sh` (+86 lines) — TTY-safe command dispatcher
- `cli/tty-filter.sh` (+137 lines) — command filter
- `cli/tty-commands.conf` — TTY command manifest
### New Domain Modules
- `nulib/images/` — golden image lifecycle (`create`, `delete`, `list`, `state`,
`update`, `watch`)
- `nulib/workspace/state.nu` (+641 lines) — workspace state model
- `nulib/workspace/sync.nu` (+148 lines) — workspace synchronization
- `nulib/main_provisioning/bootstrap.nu` — platform bootstrap
- `nulib/main_provisioning/cluster-deploy.nu` — component/taskserv dispatch
- `nulib/main_provisioning/fip.nu` (+421 lines) — floating IP handler
- `nulib/main_provisioning/state.nu` — state command
- `nulib/main_provisioning/commands/state.nu`, `commands/build.nu`,
`commands/integrations/auth.nu`, `commands/utilities/alias.nu`
- `nulib/main_provisioning/commands/platform.nu` — major expansion (+874 lines)
### Config Loader Overhaul
- `lib_provisioning/config/loader/core.nu` — slimmed (759 lines of legacy paths)
- `lib_provisioning/config/cache/core.nu` — refactored (454 lines of dead paths)
- `lib_provisioning/config/cache/nickel.nu` — simplified
- Removed: `lib_provisioning/config/loaders/file_loader.nu` (330 lines)
- Added: `config/accessor-minimal.nu`, `config/accessor/functions.nu` helpers
### Scripts & Tooling
- `nulib/scripts/` — query-* family (clusters/infra/providers/servers/taskservs/
workspace-info), validate-command, validate-config
- `scripts/auto-refactor-priority.nu`, `scripts/batch-refactor.sh`
- `scripts/build-nixos-image-remote.sh`, `scripts/deploy-cp-server.sh`
### CLI Modular Subcommands
New top-level Nu modules referenced by the bash dispatcher:
`provisioning-batch.nu`, `provisioning-bootstrap.nu`, `provisioning-cluster.nu`,
`provisioning-component.nu`, `provisioning-extension.nu`, `provisioning-job.nu`,
`provisioning-platform.nu`, `provisioning-server.nu`, `provisioning-state.nu`,
`provisioning-status.nu`, `provisioning-taskserv.nu`, `provisioning-volume.nu`,
`provisioning-workflow.nu`.
### Tests
- `nulib/tests/test_workspace_state.nu` (+351 lines)
- Updates to `test_oci_registry.nu`, `test_services.nu`
### Statistics
| Area | Files | Lines +/ |
| ---- | ----- | --------- |
| DAG + Components + Workflows | 8 | +1800 / 50 |
| Commands-registry + dispatch | 6 | +900 / 200 |
| Platform service manager | 5 | +1700 / 300 |
| Config loader/cache refactor | 10 | +400 / 1500 |
| TTY replacement | 4 | +250 / 515 |
| New subcommand modules | 13 | +1700 / 0 |
| **Total staged** | **242 files** | **+21949 / 6012** |
---
## 🔄 Previous Release (2026-01-14)
### Terminology Migration: Cluster → Taskserv

122
README.md
View file

@ -28,48 +28,60 @@ The Core Engine provides:
```text
provisioning/core/
├── cli/ # Command-line interface
│ └── provisioning # Main CLI entry point (211 lines, 84% reduction)
│ ├── provisioning # Main bash wrapper (command-registry cache aware)
│ ├── tty-dispatch.sh # TTY-safe dispatcher (replaces shlib)
│ ├── tty-filter.sh # TTY command filter
│ └── tty-commands.conf # TTY command manifest
├── nulib/ # Core Nushell libraries
│ ├── lib_provisioning/ # Core provisioning libraries
│ │ ├── config/ # Configuration loading and management
│ │ ├── utils/ # Utility functions (SSH, validation, logging)
│ │ ├── providers/ # Provider abstraction layer
│ │ ├── secrets/ # Secrets management (SOPS integration)
│ │ ├── workspace/ # Workspace management
│ │ └── infra_validator/ # Infrastructure validation engine
│ ├── main_provisioning/ # CLI command handlers
│ │ ├── flags.nu # Centralized flag handling
│ │ ├── dispatcher.nu # Command routing (80+ shortcuts)
│ │ ├── help_system.nu # Categorized help system
│ │ └── commands/ # Domain-focused command modules
│ ├── commands-registry.ncl # Command catalog (Nickel → JSON cache)
│ ├── lib_provisioning/ # Core provisioning libraries
│ │ ├── config/ # Hierarchical loader, cache, DAG loader
│ │ ├── platform/ # Service manager, startup, bootstrap, health
│ │ ├── utils/ # SSH, logging, nickel_processor, path-utils
│ │ ├── plugins/ # auth, kms, orchestrator, secretumvault
│ │ ├── providers/ # Provider registry and loader
│ │ ├── workspace/ # Workspace config, verification, enforcement
│ │ └── infra_validator/ # Schema-aware validation engine
│ ├── main_provisioning/ # CLI command handlers
│ │ ├── dispatcher.nu # Command routing (80+ shortcuts)
│ │ ├── dag.nu # `dag show/validate/export`
│ │ ├── components.nu # Components + capabilities queries
│ │ ├── workflow.nu # Workflow engine (topo sort, NATS events)
│ │ ├── bootstrap.nu # Platform bootstrap
│ │ ├── cluster-deploy.nu # Component/taskserv dispatch
│ │ ├── ontoref-queries.nu # on+re-aware CLI queries
│ │ └── commands/ # Domain-focused command modules
│ ├── components/ # Component dispatch module (NEW)
│ ├── images/ # Golden image lifecycle (create/list/update/watch)
│ ├── servers/ # Server management modules
│ ├── taskservs/ # Task service modules
│ ├── taskservs/ # Task service modules (+ dag-executor)
│ ├── clusters/ # Cluster management modules
│ └── workflows/ # Workflow orchestration modules
├── scripts/ # Utility scripts
│ └── test/ # Test automation
└── resources/ # Images and logos
│ ├── workflows/ # Workflow orchestration modules
│ ├── workspace/ # Workspace state + sync
│ └── scripts/ # In-repo nushell scripts (query-*, validate-*)
├── scripts/ # Utility scripts (refactor, deploy, manage-ports)
└── services/ # Service definitions
```
## Installation
### Prerequisites
- **Nushell 0.109.0+** - Primary shell and scripting environment
- **Nushell 0.112.2** - Primary shell and scripting environment
- **Nickel 1.15.1+** - Configuration language for infrastructure definitions
- **SOPS 3.10.2+** - Secrets management (optional but recommended)
- **Age 1.2.1+** - Encryption tool for secrets (optional)
### Adding to PATH
To use the CLI globally, add it to your PATH:
Recommended installation uses a symlink plus the `prvng` shell alias:
```bash
# Create symbolic link
ln -sf "$(pwd)/provisioning/core/cli/provisioning" /usr/local/bin/provisioning
# Symlink the bash wrapper into ~/.local/bin
ln -sf "$(pwd)/provisioning/core/cli/provisioning" "$HOME/.local/bin/provisioning"
# Or add to PATH in your shell config (~/.bashrc, ~/.zshrc, etc.)
export PATH="$PATH:/path/to/project-provisioning/provisioning/core/cli"
# Optional shell alias (add to ~/.bashrc / ~/.zshrc)
alias prvng='provisioning'
```
Verify installation:
@ -77,6 +89,7 @@ Verify installation:
```text
provisioning version
provisioning help
prvng s list # alias + single-char shortcut
```
## Quick Start
@ -120,6 +133,34 @@ provisioning cluster create my-cluster
provisioning server ssh hostname-01
```
### DAG, Components & Workflows
```bash
# Inspect workspace DAG composition (nodes, edges, health gates)
provisioning dag show --infra wuji
provisioning dag validate --infra wuji
provisioning dag export --infra wuji --format dot
# Components and extension capabilities
provisioning component list
provisioning component info postgresql
provisioning extensions capabilities
provisioning extensions graph
# Workflows (topological scheduling + NATS events)
provisioning workflow list
provisioning workflow run deploy-services --infra libre-daoshi
provisioning workflow status <id>
```
### Command Registry & Fast Path
Every `prvng`/`provisioning` invocation validates the command against a JSON cache
rebuilt from `nulib/commands-registry.ncl` whenever the source is newer. Single-char
aliases (`s`, `t`, `c`, `e`, `w`, `j`, `b`, `o`, `a`) are expanded in the bash wrapper
before dispatch. Adding a new top-level command requires a registry entry **plus** a
dispatch case in `cli/provisioning` — see `nulib/main_provisioning/ADDING_COMMANDS.md`.
### Quick Reference
For fastest command reference:
@ -363,7 +404,7 @@ The project follows a three-phase migration:
### Required
- **Nushell 0.109.0+** - Shell and scripting language
- **Nushell 0.112.2** - Shell and scripting language
- **Nickel 1.15.1+** - Configuration language
### Recommended
@ -491,14 +532,35 @@ See project root LICENSE file.
## Recent Updates
### 2026-04-17 - DAG architecture, commands-registry, Nushell 0.110/0.112 refactor
- **Unified Component Architecture**: `components/`, `workflow.nu`, and `components.nu`
implement the libre-daoshi unified model (ComponentDef, WorkflowDef, capabilities).
See `memory/unified_component_arch.md` and ADRs 020/021.
- **Three-layer DAG**: `dag.nu` + `lib_provisioning/config/loader/dag.nu` add
`dag show/validate/export` backed by `schemas/lib/dag/*.ncl`; orchestrator emits
NATS events via `WorkspaceComposition::into_workflow`.
- **Commands-registry cache**: `nulib/commands-registry.ncl` feeds the bash wrapper's
`_validate_command`; fast-path single-char alias expansion avoids cold Nu startup.
- **Platform service manager**: new `platform/service-manager.nu`, `platform/startup.nu`,
and `bootstrap.nu` consolidate autostart, health checks, and lifecycle.
- **Nushell 0.112.2 compliance**: `scripts/refactor-try-catch*.nu` drove the
migration — no `try/catch`, no bash redirections, all external commands prefixed.
- **TTY stack**: `shlib/*-tty.sh` removed; replaced by `cli/tty-dispatch.sh`,
`cli/tty-filter.sh`, and `cli/tty-commands.conf`.
- **New domain modules**: `images/` (golden image lifecycle), `workspace/state.nu` +
`workspace/sync.nu`, `main_provisioning/ontoref-queries.nu`, `main_provisioning/fip.nu`,
`main_provisioning/state.nu`, `main_provisioning/extensions.nu`.
- **Config loader overhaul**: `loader/core.nu` slimmed (759 lines of legacy paths),
`cache/core.nu` refactored, `loaders/file_loader.nu` removed.
### 2026-01-14 - Terminology Migration & i18n
- **Cluster → Taskserv**: Complete refactoring of cluster references to taskserv throughout nulib/ modules
- **Fluent i18n System**: Internationalization framework with automatic locale detection
- **Cluster → Taskserv**: Complete refactoring across nulib/ modules
- **Fluent i18n System**: Automatic locale detection with en-US fallback
- Enhanced ANSI output formatting for improved CLI readability
- Updated handlers, utilities, and discovery modules for consistency
- Locale support: en-US (default) with framework for es-ES, fr-FR, de-DE, etc.
---
**Maintained By**: Core Team
**Last Updated**: 2026-01-14
**Last Updated**: 2026-04-17

467
cli/README.md Normal file
View file

@ -0,0 +1,467 @@
# Provisioning CLI - Flow-Aware TTY Command Management
## Architecture Overview
The provisioning wrapper (`provisioning/core/cli/provisioning`) is a **flow controller** that manages three execution paths for command handling:
1. **Standalone TTY** - Interactive commands that exit after execution
2. **Pipeline TTY** - Interactive commands that output for piping to other commands
3. **Regular** - Standard Nushell command processing
This design enables:
- Interactive commands (TTY input) without blocking Nushell
- Inter-command piping of TTY output to subsequent commands
- Same-command flow (TTY input → Nushell processing in one execution)
- Daemon optimization for non-interactive commands
## How Flow Management Works
### Execution Flow
```text
User Command: provisioning <cmd> <args>
Bash wrapper (provisioning)
┌──────────────────────────────────────┐
│ Phase 1: TTY Command Detection │
│ - Read tty-commands.conf registry │
│ - Match command pattern │
└──────────────────────────────────────┘
├─→ Not a TTY command → Continue to Nushell (normal processing)
└─→ TTY command found → Check flow type
├─→ flow=exit → Execute wrapper, exit immediately
├─→ flow=pipe → Execute wrapper, output to stdout, exit (allows piping)
└─→ flow=continue → Execute wrapper, capture output, continue to Nushell
($env.TTY_OUTPUT available in Nushell)
```
### Flow Types Explained
#### 1. Standalone TTY Commands (flow=exit)
**Use case**: Interactive forms, setup wizards, authentication dialogs
**Example**: `provisioning setup wizard`
**Flow**:
```bash
Bash wrapper → TTY filter detects "setup wizard" → flow=exit
Execute wrapper: core/shlib/setup-wizard-tty.sh
User interaction (TypeDialog form)
Exit wrapper → Exit bash wrapper
Never reaches Nushell
```
**Registry entry**:
```bash
"setup wizard" "core/shlib/setup-wizard-tty.sh" "exit"
```
#### 2. Pipeline TTY Commands (flow=pipe)
**Use case**: Getting user input to pipe to another command
**Example**: `provisioning auth get-key | provisioning deploy --api-key-stdin`
**Flow**:
```bash
Bash wrapper → TTY filter detects "auth get-key" → flow=pipe
Execute wrapper: core/shlib/auth-get-key-tty.sh
User provides API key via TTY prompt
Wrapper outputs API key to stdout
Exit wrapper (process exits, pipe has captured output)
Next command receives API key from stdin
```
**Registry entry**:
```bash
"auth get-key" "core/shlib/auth-get-key-tty.sh" "pipe"
```
**Wrapper requirements** (flow=pipe):
- Must output result to stdout
- Output must be newline-terminated
- Exit with proper code (0=success, non-zero=error)
#### 3. Continue-to-Nushell TTY Commands (flow=continue)
**Use case**: TTY input that needs further processing in Nushell
**Example**: `provisioning auth integrate --provider azure`
**Flow**:
```bash
Bash wrapper → TTY filter detects "auth integrate" → flow=continue
Execute wrapper: core/shlib/auth-integrate-tty.sh
User provides credentials via TTY prompt
Wrapper outputs credentials (usually JSON) to stdout
Filter CAPTURES output to $TTY_OUTPUT environment variable
Set $env.PROVISIONING_BYPASS_DAEMON=true (skip daemon)
Return 0 WITHOUT EXITING (continue to Nushell)
Nushell dispatcher receives both:
- CLI args: --provider azure
- TTY output: $env.TTY_OUTPUT (credentials JSON)
Nushell script processes both, completes integration
```
**Registry entry**:
```bash
"auth integrate" "core/shlib/auth-integrate-tty.sh" "continue"
```
**Wrapper requirements** (flow=continue):
- Must output result to stdout (usually JSON for structured data)
- Exit with proper code (0=success, non-zero=error)
**Nushell script requirements** (receives flow=continue output):
```nushell
export def "provisioning auth integrate" [--provider: string] {
# Check if TTY output exists (guard pattern)
let tty_output = ($env.TTY_OUTPUT? | default "")
if ($tty_output | is-empty) {
error make {msg: "No credentials provided via TTY"}
}
# Parse TTY output (credentials)
let credentials = ($tty_output | from json)
# Use both TTY input ($credentials) and CLI args ($provider)
# Complete integration logic...
# Clear sensitive data after use
hide-env TTY_OUTPUT
}
```
#### 4. Regular Commands
**Use case**: Standard provisioning operations
**Example**: `provisioning server list`
**Flow**:
```bash
Bash wrapper → TTY filter checks registry → Not found → Return 1
Continue to normal processing:
- Fast-path checks (help, workspace, env, etc.)
- Daemon check (if applicable)
- Nushell dispatcher
```
## Registry Format
**File**: `provisioning/core/cli/tty-commands.conf`
**Three-field format**: `"PATTERN" "WRAPPER_PATH" "FLOW_TYPE"`
```bash
# Exact command match (e.g., "setup wizard" matches "provisioning setup wizard")
"setup wizard" "core/shlib/setup-wizard-tty.sh" "exit"
# Paths are relative to $PROVISIONING
"auth get-key" "core/shlib/auth-get-key-tty.sh" "pipe"
# Flow types: exit | pipe | continue
"auth integrate" "core/shlib/auth-integrate-tty.sh" "continue"
```
### Flow Type Decision Matrix
| Interaction | Flow Type | Example |
| ----------- | --------- | ------- |
| Interactive form, no output needed | `exit` | Setup wizard, auth login |
| User input → pipe to next command | `pipe` | API key for piping to deploy |
| User input → same-command Nushell processing | `continue` | Credentials for integration |
## Adding New TTY Commands
### Step 1: Create Wrapper Script
Create wrapper in `provisioning/core/shlib/`:
```bash
#!/bin/bash
set -euo pipefail
main() {
local input
# Get input from user
read -rsp "Prompt: " input
echo # Newline
# For flow=pipe: output to stdout
# For flow=continue: output to stdout (will be captured by filter)
echo "$input"
return 0
}
main "$@"
```
Make it executable:
```bash
chmod +x provisioning/core/shlib/your-wrapper-tty.sh
```
### Step 2: Add Registry Entry
Edit `provisioning/core/cli/tty-commands.conf`:
```bash
# Standalone TTY
"your command" "core/shlib/your-wrapper-tty.sh" "exit"
# Pipeline TTY
"get something" "core/shlib/get-something-tty.sh" "pipe"
# Continue-to-Nushell TTY
"setup something" "core/shlib/setup-something-tty.sh" "continue"
```
### Step 3: No Wrapper Modifications Required
The provisioning wrapper automatically:
- Reads registry
- Matches command pattern
- Routes based on flow type
- Handles all three flows
**No need to modify provisioning wrapper for new commands!**
## Wrapper Script Requirements
### For All Wrappers
- **Shebang**: `#!/bin/bash`
- **Safety**: `set -euo pipefail`
- **Arguments**: Accept `"${@}"` from wrapper
- **Exit codes**: 0=success, non-zero=error
- **Validation**: `shellcheck` passes without warnings
### For flow=exit Wrappers
- Complete all interaction in wrapper
- Exit with proper code (0=success, non-zero=error)
- Output shown directly to user (from wrapper)
### For flow=pipe Wrappers
- Get input from user (TTY)
- Output result to stdout
- Output must be newline-terminated
- Exit with proper code (0=success, non-zero=error)
### For flow=continue Wrappers
- Get input from user (TTY)
- Output result to stdout (usually JSON)
- Exit with proper code (0=success, non-zero=error)
- Filter captures output → $TTY_OUTPUT
- Nushell script reads $env.TTY_OUTPUT
## Environment Variables
### Exported by Filter (flow=continue only)
- **`$TTY_OUTPUT`**: Captured output from wrapper (available in Nushell as `$env.TTY_OUTPUT`)
- **`$PROVISIONING_BYPASS_DAEMON`**: Set to "true" to skip daemon (flow=continue automatically sets this)
- **`$TTY_WRAPPER_EXECUTED`**: Set to "true" when TTY wrapper was executed
### Usage in Nushell
```nushell
# Access TTY output in Nushell script
export def "provisioning auth integrate" [--provider: string] {
let tty_output = ($env.TTY_OUTPUT? | default "")
# Parse if JSON
let creds = ($tty_output | from json)
# Use both TTY output and CLI args
integration-logic $provider $creds
# Clear after use (security)
hide-env TTY_OUTPUT
}
```
## Daemon Interaction
The flow filter intelligently manages daemon usage:
### For flow=exit and flow=pipe
- ✅ **Daemon can be used** - No stdin required
- No output needs to be captured and passed to Nushell
- Daemon optimization available (~100ms startup improvement)
### For flow=continue
- ❌ **Daemon MUST be bypassed** - stdin required for TTY interaction
- `PROVISIONING_BYPASS_DAEMON=true` automatically set by filter
- Direct Nushell execution (preserves stdin for TTY)
- Zero overhead (same as non-daemon path)
## Testing TTY Commands
### Test Standalone (flow=exit)
```bash
provisioning setup wizard
# Expected: TypeDialog form, user interaction, exits
```
### Test Pipeline (flow=pipe)
```bash
provisioning auth get-key | wc -c
# Expected: Prompts for API key, outputs to pipe
```
### Test Continue (flow=continue)
```bash
provisioning auth integrate --provider azure
# Expected: Prompts for credentials, passes to Nushell with $env.TTY_OUTPUT
```
### Test Regular Command
```bash
provisioning server list
# Expected: Normal Nushell processing
```
## Troubleshooting
### Command Not Executed
- **Check**: Is command in tty-commands.conf?
- **Check**: Does pattern exactly match command?
- **Check**: Is wrapper path correct and executable?
### Wrapper Not Found
- **Error message**: `Warning: TTY wrapper not found or not executable: /path/to/wrapper`
- **Check**: File exists at `$PROVISIONING/wrapper-path`
- **Check**: File is executable: `chmod +x wrapper-path`
### Output Not Piping (flow=pipe)
- **Check**: Wrapper outputs to stdout (not stderr)
- **Check**: Output is newline-terminated: `echo "output"`
- **Check**: No daemon interference (PROVISIONING_BYPASS_DAEMON not set)
### Nushell Not Receiving Output (flow=continue)
- **Check**: `$env.TTY_OUTPUT` accessible in Nushell: `echo $env.TTY_OUTPUT`
- **Check**: Output format (usually JSON): `echo $env.TTY_OUTPUT | from json`
- **Check**: Wrapper exits with 0: `echo $?`
## Implementation Details
### Filter Location and Function
**File**: `provisioning/core/cli/tty-filter.sh`
**Function**: `filter_tty_command()`
**Lines**: ~104 (includes documentation and three flow paths)
### Integration in Wrapper
**File**: `provisioning/core/cli/provisioning`
**Lines**: ~20 (sources filter, calls function, continues to Nushell)
### Registry Parsing
- **File**: `provisioning/core/cli/tty-commands.conf`
- **Method**: Line-by-line bash read (no jq dependency)
- **Format**: Three-field bash array (bash-compatible)
- **Sections**: Organized by flow type for clarity
## Performance Implications
### startup time
- **flow=exit/pipe**: Daemon available for startup optimization (~100ms improvement)
- **flow=continue**: Daemon bypassed (stdin needed), ~500ms traditional path
- **Regular commands**: Normal daemon/non-daemon path selection
### Memory
- **flow=continue**: Wrapper output stored in `$TTY_OUTPUT` environment variable
- Typical size: < 1KB (credentials, keys, etc.)
- Cleared after Nushell processing (or via `hide-env`)
## Security Considerations
### Sensitive Data in $TTY_OUTPUT
- **Credentials** captured in `$TTY_OUTPUT`
- **Nushell scripts should clear after use**: `hide-env TTY_OUTPUT`
- **Wrapper output may be logged**: Use standard Unix conventions (hide passwords from output)
### Wrapper Location Restriction
- Wrappers should be in `provisioning/core/shlib/` or `provisioning/scripts/`
- Registry reads only wrappers from these trusted locations
- Pattern validation prevents arbitrary script execution
### No Shell Injection
- All variables quoted: `"$variable"`
- No eval or command substitution with user input
- Pattern matching uses exact string match (no regex)
## Related Files
- **Filter**: `provisioning/core/cli/tty-filter.sh`
- **Registry**: `provisioning/core/cli/tty-commands.conf`
- **Wrapper**: `provisioning/core/cli/provisioning`
- **Example wrappers**: `provisioning/core/shlib/auth-get-key-tty.sh`, `provisioning/core/shlib/auth-integrate-tty.sh`
## Key Insights
The provisioning wrapper is not just a pass-through - it's a **flow controller** that:
1. **Detects TTY requirements** (registry matching)
2. **Manages execution paths** (three flows: exit, pipe, continue)
3. **Controls exit behavior** (standalone vs pipeline vs same-command)
4. **Enables inter-command piping** (TTY output to pipes)
5. **Supports Nushell integration** (TTY→Nushell continuation)
6. **Optimizes with daemon** (skip when stdin needed)
This solves:
- "el tema no es sólo un filter" → ✅ Flow controller with three execution paths
- "cómo gestionar el flow por medio del provisioning command" → ✅ Registry + flow types
- "usamos tty para input de una API key, se lo pasamos a un script de nushell" → ✅ Pipeline + continue flows
---
**Version**: 1.0.0
**Last Updated**: January 2026
**Status**: ✅ Production Ready

752
cli/new_provisioning Executable file
View file

@ -0,0 +1,752 @@
#!/usr/bin/env bash
# Info: Script to run Provisioning
# Author: Jesus Perez Lorenzo
# Release: 3.0.11
# Date: 2026-01-14
set +o errexit
set +o pipefail
# Debug: log startup
[ "${PROVISIONING_DEBUG_STARTUP:-false}" = "true" ] && echo "[DEBUG] Wrapper started with args: $@" >&2
export NU=$(type -P nu)
_release() {
grep "^# Release:" "$0" | sed "s/# Release: //g"
}
export PROVISIONING_VERS=$(_release)
set -o allexport
## shellcheck disable=SC1090
[ -n "$PROVISIONING_ENV" ] && [ -r "$PROVISIONING_ENV" ] && source "$PROVISIONING_ENV"
[ -r "../env-provisioning" ] && source ../env-provisioning
[ -r "env-provisioning" ] && source ./env-provisioning
#[ -r ".env" ] && source .env set
# Disable provisioning logo/banner output
export PROVISIONING_NO_TITLES=true
set +o allexport
export PROVISIONING=${PROVISIONING:-/usr/local/provisioning}
PROVIISONING_WKPATH=${PROVIISONING_WKPATH:-/tmp/tmp.}
RUNNER="provisioning"
PROVISIONING_MODULE=""
PROVISIONING_MODULE_TASK=""
# Safe argument handling - use default empty value if unbound
[ "${1:-}" == "" ] && shift
[ -z "$NU" ] || [ "${1:-}" == "install" ] || [ "${1:-}" == "reinstall" ] || [ "${1:-}" == "mode" ] && exec bash $PROVISIONING/core/bin/install_nu.sh $PROVISIONING ${1:-} ${2:-}
[ "${1:-}" == "rmwk" ] && rm -rf "$PROVIISONING_WKPATH"* && echo "$PROVIISONING_WKPATH deleted" && exit
[ "${1:-}" == "-x" ] && debug=-x && export PROVISIONING_DEBUG=true && shift
[ "${1:-}" == "-xm" ] && export PROVISIONING_METADATA=true && shift
[ "${1:-}" == "nu" ] && export PROVISIONING_DEBUG=true
[ "${1:-}" == "--x" ] && set -x && debug=-x && export PROVISIONING_DEBUG=true && shift
[ "${1:-}" == "-i" ] || [ "${2:-}" == "-i" ] && echo "$(basename "$0") $(grep "^# Info:" "$0" | sed "s/# Info: //g") " && exit
[ "${1:-}" == "-v" ] || [ "${1:-}" == "--version" ] || [ "${2:-}" == "-v" ] && _release && exit
# ════════════════════════════════════════════════════════════════════════════════
# FLOW-AWARE TTY COMMAND FILTER
# Manages three execution flows: exit (standalone), pipe (inter-command), continue (Nushell)
# Registry: provisioning/core/cli/tty-commands.conf
# Filter: provisioning/core/cli/tty-filter.sh
# ════════════════════════════════════════════════════════════════════════════════
if [ -f "$PROVISIONING/core/cli/tty-filter.sh" ]; then
# Source filter function
# shellcheck source=/dev/null
source "$PROVISIONING/core/cli/tty-filter.sh"
# Try to filter TTY command (full command line as single string)
# Return codes:
# - filter_tty_command returns 0: flow=continue case handled, continue to Nushell with $TTY_OUTPUT
# - filter_tty_command exits: flow=exit/pipe case completed (already exited)
# - filter returns 1: not a TTY command, continue to normal processing
if filter_tty_command "$@"; then
# Flow=continue: TTY wrapper executed, output in $TTY_OUTPUT, bypass daemon
# $env.PROVISIONING_BYPASS_DAEMON and $env.TTY_OUTPUT available to Nushell
: # Continue to Nushell dispatcher below
fi
fi
CMD_ARGS=$@
# Note: Flag ordering is handled by Nushell's reorder_args function
# which automatically reorders flags before positional arguments.
# Flags can be placed anywhere on the command line.
case "${1:-}" in
# Note: "setup" is now handled by the main provisioning CLI dispatcher
# No special module handling needed
-mod)
PROVISIONING_MODULE=$(echo "$2" | sed 's/ //g' | cut -f1 -d"|")
PROVISIONING_MODULE_TASK=$(echo "$2" | sed 's/ //g' | cut -f2 -d"|")
[ "$PROVISIONING_MODULE" == "$PROVISIONING_MODULE_TASK" ] && PROVISIONING_MODULE_TASK=""
shift 2
CMD_ARGS=$@
[ "${PROVISIONING_DEBUG_STARTUP:-false}" = "true" ] && echo "[DEBUG] -mod detected: MODULE=$PROVISIONING_MODULE, TASK=$PROVISIONING_MODULE_TASK, CMD_ARGS=$CMD_ARGS" >&2
;;
esac
NU_ARGS=""
DEFAULT_CONTEXT_TEMPLATE="default_context.yaml"
case "$(uname | tr '[:upper:]' '[:lower:]')" in
linux)
PROVISIONING_USER_CONFIG="$HOME/.config/provisioning/nushell"
PROVISIONING_CONTEXT_PATH="$HOME/.config/provisioning/$DEFAULT_CONTEXT_TEMPLATE"
PROVISIONING_USER_PLATFORM="$HOME/.config/provisioning/platform"
;;
darwin)
PROVISIONING_USER_CONFIG="$HOME/Library/Application Support/provisioning/nushell"
PROVISIONING_CONTEXT_PATH="$HOME/Library/Application Support/provisioning/$DEFAULT_CONTEXT_TEMPLATE"
PROVISIONING_USER_PLATFORM="$HOME/Library/Application Support/provisioning/platform"
;;
*)
PROVISIONING_USER_CONFIG="$HOME/.config/provisioning/nushell"
PROVISIONING_CONTEXT_PATH="$HOME/.config/provisioning/$DEFAULT_CONTEXT_TEMPLATE"
PROVISIONING_USER_PLATFORM="$HOME/.config/provisioning/platform"
;;
esac
# ════════════════════════════════════════════════════════════════════════════════
# DAEMON ROUTING - Try daemon for all commands (except setup/help/interactive)
# Falls back to traditional handlers if daemon unavailable
# ════════════════════════════════════════════════════════════════════════════════
DAEMON_ENDPOINT="http://127.0.0.1:9091/execute"
# Function to execute command via daemon
execute_via_daemon() {
local cmd="$1"
shift
# Build JSON array of arguments (simple bash)
local args_json="["
local first=1
for arg in "$@"; do
[ $first -eq 0 ] && args_json="$args_json,"
args_json="$args_json\"$(echo "$arg" | sed 's/"/\\"/g')\""
first=0
done
args_json="$args_json]"
# Determine timeout based on command type
# Heavy commands (create, delete, update) get longer timeout
local timeout=0.5
case "$cmd" in
create | delete | update | setup | init) timeout=5 ;;
*) timeout=0.2 ;;
esac
# Make request and extract stdout
curl -s -m $timeout -X POST "$DAEMON_ENDPOINT" \
-H "Content-Type: application/json" \
-d "{\"command\":\"$cmd\",\"args\":$args_json,\"timeout_ms\":30000}" 2>/dev/null |
sed -n 's/.*"stdout":"\(.*\)","execution.*/\1/p' |
sed 's/\\n/\n/g'
}
# Try daemon ONLY for lightweight commands (list, show, status)
# Skip daemon for heavy commands (create, delete, update) because bash wrapper is slow
# ALSO skip daemon for flow=continue commands (need stdin for TTY interaction)
if [ "${PROVISIONING_BYPASS_DAEMON:-}" != "true" ] && ([ "${1:-}" = "server" ] || [ "${1:-}" = "s" ]); then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
# Light command - try daemon
[ -n "${PROVISIONING_DEBUG:-}" ] && [ "${PROVISIONING_DEBUG:-}" = "true" ] && echo "⚡ Attempting daemon execution..." >&2
DAEMON_OUTPUT=$(execute_via_daemon "$@" 2>/dev/null)
if [ -n "$DAEMON_OUTPUT" ]; then
echo "$DAEMON_OUTPUT"
exit 0
fi
[ -n "${PROVISIONING_DEBUG:-}" ] && [ "${PROVISIONING_DEBUG:-}" = "true" ] && echo "⚠️ Daemon unavailable, using traditional handlers..." >&2
fi
# NOTE: Command reordering (server create -> create server) has been removed.
# The Nushell dispatcher in provisioning/core/nulib/main_provisioning/dispatcher.nu
# handles command routing correctly and expects "server create" format.
# The reorder_args function in provisioning script handles any flag reordering needed.
fi
# ════════════════════════════════════════════════════════════════════════════════
# FAST-PATH: Commands that don't need full config loading or platform bootstrap
# These commands use lib_minimal.nu for <100ms execution
# (ONLY REACHED if daemon is not available)
# ═══<E29590><E29590><EFBFBD>════════════════════════════════════════════════════════════════════════════
# Help commands fast-path (uses help_minimal.nu)
# Detects "help" in ANY argument position, not just first
help_category=""
help_found=false
# Check if first arg is empty (no args provided) - treat as help request
if [ -z "${1:-}" ]; then
help_found=true
else
# Loop through all arguments to find help variant and extract category
for arg in "$@"; do
case "$arg" in
help|-h|--help|--helpinfo)
help_found=true
;;
-*)
# Skip flags (like -x, -xm, -i, -v, etc.)
;;
*)
# First non-flag, non-help argument becomes the category
if [ "$help_category" = "" ]; then
help_category="$arg"
fi
;;
esac
done
fi
# Execute help fast-path if help was requested
if [ "$help_found" = true ]; then
# Export LANG explicitly to ensure locale detection works in nu subprocess
export LANG
$NU -n -c "source '$PROVISIONING/core/nulib/help_minimal.nu'; provisioning-help '$help_category' | print" 2>/dev/null
exit $?
fi
# Workspace operations (fast-path)
if [ "${1:-}" = "workspace" ] || [ "${1:-}" = "ws" ]; then
case "${2:-}" in
"list" | "")
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-list | get ok | table" 2>/dev/null
exit $?
;;
"active")
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-active" 2>/dev/null
exit $?
;;
"info")
if [ -n "${3:-}" ]; then
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-info '$3'" 2>/dev/null
else
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-active | workspace-info \$in" 2>/dev/null
fi
exit $?
;;
esac
# Other workspace commands (switch, register, etc.) fall through to full loading
fi
# Status/Health check (fast-path) - DISABLED to fix dispatcher loop
# Use normal dispatcher path instead of fast-path with lib_minimal.nu
# if [ "$1" = "status" ] || [ "$1" = "health" ]; then
# $NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; status-quick | table" 2>/dev/null
# exit $?
# fi
# Environment display (fast-path)
if [ "${1:-}" = "env" ] || [ "${1:-}" = "allenv" ]; then
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; env-quick | table" 2>/dev/null
exit $?
fi
# Provider list (lightweight - reads filesystem only, no module loading)
if [ "${1:-}" = "provider" ] || [ "${1:-}" = "providers" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
let provisioning = (\$env.PROVISIONING | default '/usr/local/provisioning')
let providers_base = (\$provisioning | path join 'extensions' | path join 'providers')
if not (\$providers_base | path exists) {
print 'PROVIDERS list: (none found)'
return
}
# Discover all providers from directories
let all_providers = (
ls \$providers_base | where type == 'dir' | each {|prov_dir|
let prov_name = (\$prov_dir.name | path basename)
if \$prov_name != 'prov_lib' {
{name: \$prov_name, type: 'providers', version: '0.0.1'}
} else {
null
}
} | compact
)
if (\$all_providers | length) == 0 {
print 'PROVIDERS list: (none found)'
} else {
print 'PROVIDERS list: '
print ''
\$all_providers | table
}
" 2>/dev/null
exit $?
fi
fi
# Taskserv list (fast-path) - avoid full system load
if [ "${1:-}" = "taskserv" ] || [ "${1:-}" = "task" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
# Direct implementation of taskserv discovery (no dependency loading)
# Taskservs are nested: extensions/taskservs/{category}/{name}/kcl/
let provisioning = (\$env.PROVISIONING | default '/usr/local/provisioning')
let taskservs_base = (\$provisioning | path join 'extensions' | path join 'taskservs')
if not (\$taskservs_base | path exists) {
print '📦 Available Taskservs: (none found)'
return null
}
# Discover all taskservs from nested categories
let all_taskservs = (
ls \$taskservs_base | where type == 'dir' | each {|cat_dir|
let category = (\$cat_dir.name | path basename)
let cat_path = (\$taskservs_base | path join \$category)
if (\$cat_path | path exists) {
ls \$cat_path | where type == 'dir' | each {|ts|
let ts_name = (\$ts.name | path basename)
{task: \$ts_name, mode: \$category, info: ''}
}
} else {
[]
}
} | flatten
)
if (\$all_taskservs | length) == 0 {
print '📦 Available Taskservs: (none found)'
} else {
print '📦 Available Taskservs:'
print ''
\$all_taskservs | each {|ts|
print \$\" • (\$ts.task) [(\$ts.mode)]\"
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Server list (lightweight - reads filesystem only, no config loading)
if [ "${1:-}" = "server" ] || [ "${1:-}" = "s" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
# Extract --infra flag from remaining args
INFRA_FILTER=""
shift
[ "${1:-}" = "list" ] && shift
while [ $# -gt 0 ]; do
case "${1:-}" in
--infra | -i)
INFRA_FILTER="${2:-}"
shift 2
;;
*) shift ;;
esac
done
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print 'No active workspace'
return
}
# Get workspace path from config
let user_config_path = if (\$env.HOME | path exists) {
(
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
} else {
''
}
if not (\$user_config_path | path exists) {
print 'Config not found'
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print 'Workspace not found'
return
}
let ws_path = \$ws.path
let infra_path = (\$ws_path | path join 'infra')
if not (\$infra_path | path exists) {
print 'No infrastructures found'
return
}
# Filter by infrastructure if specified
let infra_filter = \"$INFRA_FILTER\"
# List server definitions from infrastructure (filtered if --infra specified)
let servers = (
ls \$infra_path | where type == 'dir' | each {|infra|
let infra_name = (\$infra.name | path basename)
# Skip if filter is specified and doesn't match
if ((\$infra_filter | is-not-empty) and (\$infra_name != \$infra_filter)) {
[]
} else {
let servers_file = ($infra_path | path join \$infra_name | path join 'defs' | path join 'servers.ncl')
let servers_file_kcl = ($infra_path | path join \$infra_name | path join 'defs' | path join 'servers.k')
if ($servers_file | path exists) {
# Parse the Nickel servers.ncl file to extract server hostnames
let content = (open \$servers_file --raw)
# Extract hostnames from hostname = "..." patterns by splitting on quotes
let hostnames = (
\$content
| split row \"\\n\"
| where {|line| \$line | str contains \"hostname = \\\"\" }
| each {|line|
# Split by quotes to extract hostname value
let parts = (\$line | split row \"\\\"\")
if (\$parts | length) >= 2 {
\$parts | get 1
} else {
\"\"
}
}
| where {|h| (\$h | is-not-empty) }
)
\$hostnames | each {|srv_name|
{
name: \$srv_name
infrastructure: \$infra_name
path: \$servers_file
}
}
} else {
[]
}
}
} | flatten
)
if (\$servers | length) == 0 {
print '📦 Available Servers: (none configured)'
} else {
print '📦 Available Servers:'
print ''
\$servers | each {|srv|
print \$\" • (\$srv.name) [(\$srv.infrastructure)]\"
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Cluster list (lightweight - reads filesystem only)
if [ "${1:-}" = "cluster" ] || [ "${1:-}" = "cl" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print 'No active workspace'
return
}
# Get workspace path from config
let user_config_path = (
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
if not (\$user_config_path | path exists) {
print 'Config not found'
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print 'Workspace not found'
return
}
let ws_path = \$ws.path
# List all clusters from workspace
let clusters = (
if ((\$ws_path | path join '.clusters') | path exists) {
let clusters_path = (\$ws_path | path join '.clusters')
ls \$clusters_path | where type == 'dir' | each {|cl|
let cl_name = (\$cl.name | path basename)
{
name: \$cl_name
path: \$cl.name
}
}
} else {
[]
}
)
if (\$clusters | length) == 0 {
print '🗂️ Available Clusters: (none found)'
} else {
print '🗂️ Available Clusters:'
print ''
\$clusters | each {|cl|
print \$\" • (\$cl.name)\"
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Infra list (lightweight - reads filesystem only)
if [ "${1:-}" = "infra" ] || [ "${1:-}" = "inf" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print 'No active workspace'
return
}
# Get workspace path from config
let user_config_path = (
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
if not (\$user_config_path | path exists) {
print 'Config not found'
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print 'Workspace not found'
return
}
let ws_path = \$ws.path
let infra_path = (\$ws_path | path join 'infra')
if not (\$infra_path | path exists) {
print '📁 Available Infrastructures: (none configured)'
return
}
# List all infrastructures
let infras = (
ls \$infra_path | where type == 'dir' | each {|inf|
let inf_name = (\$inf.name | path basename)
let inf_full_path = (\$infra_path | path join \$inf_name)
let has_config = ((\$inf_full_path | path join 'settings.k') | path exists)
{
name: \$inf_name
configured: \$has_config
modified: \$inf.modified
}
}
)
if (\$infras | length) == 0 {
print '📁 Available Infrastructures: (none found)'
} else {
print '📁 Available Infrastructures:'
print ''
\$infras | each {|inf|
let status = if \$inf.configured { '✓' } else { '○' }
let output = \" [\" + \$status + \"] \" + \$inf.name
print \$output
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Config validation (lightweight - validates config structure without full load)
if [ "${1:-}" = "validate" ]; then
if [ "${2:-}" = "config" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
try {
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print '❌ Error: No active workspace'
return
}
# Get workspace path from config
let user_config_path = (
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
if not (\$user_config_path | path exists) {
print '❌ Error: User config not found at' \$user_config_path
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print '❌ Error: Workspace' \$active_ws 'not found in config'
return
}
let ws_path = \$ws.path
# Validate workspace structure
let required_dirs = ['infra', 'config', '.clusters']
let infra_path = (\$ws_path | path join 'infra')
let config_path = (\$ws_path | path join 'config')
let missing_dirs = \$required_dirs | where { not ((\$ws_path | path join \$in) | path exists) }
if (\$missing_dirs | length) > 0 {
print '⚠️ Warning: Missing directories:' (\$missing_dirs | str join ', ')
}
# Validate infrastructures have required files
if (\$infra_path | path exists) {
let infras = (ls \$infra_path | where type == 'dir')
let invalid_infras = (
\$infras | each {|inf|
let inf_name = (\$inf.name | path basename)
let inf_full_path = (\$infra_path | path join \$inf_name)
if not ((\$inf_full_path | path join 'settings.k') | path exists) {
\$inf_name
} else {
null
}
} | compact
)
if (\$invalid_infras | length) > 0 {
print '⚠️ Warning: Infrastructures missing settings.k:' (\$invalid_infras | str join ', ')
}
}
# Validate user config structure
let has_active = ((\$config | get --optional active_workspace) != null)
let has_workspaces = ((\$config | get --optional workspaces) != null)
let has_preferences = ((\$config | get --optional preferences) != null)
if not \$has_active {
print '⚠️ Warning: Missing active_workspace in user config'
}
if not \$has_workspaces {
print '⚠️ Warning: Missing workspaces list in user config'
}
if not \$has_preferences {
print '⚠️ Warning: Missing preferences in user config'
}
# Summary
print ''
print '✓ Configuration validation complete for workspace:' \$active_ws
print ' Path:' \$ws_path
print ' Status: Valid (with warnings, if any listed above)'
} catch {|err|
print '❌ Validation error:' \$err
}
" 2>/dev/null
exit $?
fi
fi
if [ ! -d "$PROVISIONING_USER_CONFIG" ] || [ ! -r "$PROVISIONING_CONTEXT_PATH" ]; then
[ ! -x "$PROVISIONING/core/nulib/provisioning setup" ] && echo "$PROVISIONING/core/nulib/provisioning setup not found" && exit 1
cd "$PROVISIONING/core/nulib"
./"provisioning setup"
echo ""
read -p "Use [enter] to continue or [ctrl-c] to cancel"
fi
[ ! -r "$PROVISIONING_USER_CONFIG/config.nu" ] && echo "$PROVISIONING_USER_CONFIG/config.nu not found" && exit 1
[ ! -r "$PROVISIONING_USER_CONFIG/env.nu" ] && echo "$PROVISIONING_USER_CONFIG/env.nu not found" && exit 1
NU_ARGS=(--config "$PROVISIONING_USER_CONFIG/config.nu" --env-config "$PROVISIONING_USER_CONFIG/env.nu")
export PROVISIONING_ARGS="$CMD_ARGS" NU_ARGS="$NU_ARGS"
#export NU_ARGS=${NU_ARGS//Application Support/Application\\ Support}
# Suppress repetitive config export output during initialization
export PROVISIONING_QUIET_EXPORT="true"
# Export NU_LIB_DIRS so Nushell can find modules during parsing
export NU_LIB_DIRS="$PROVISIONING/core/nulib:/opt/provisioning/core/nulib:/usr/local/provisioning/core/nulib"
# ============================================================================
# DAEMON ROUTING - ENABLED (Phase 3.7: CLI Daemon Integration)
# ============================================================================
# Redesigned daemon with pre-loaded Nushell environment (no CLI callback).
# Routes eligible commands to HTTP daemon for <100ms execution.
# Gracefully falls back to full load if daemon unavailable.
#
# ARCHITECTURE:
# 1. Check daemon health (curl with 5ms timeout)
# 2. Route eligible commands to daemon via HTTP POST
# 3. Fall back to full load if daemon unavailable
# 4. Zero breaking changes (graceful degradation)
#
# PERFORMANCE:
# - With daemon: <100ms for ALL commands
# - Without daemon: ~430ms (normal behavior)
# - Daemon fallback: Automatic, user sees no difference
if [ -n "$PROVISIONING_MODULE" ]; then
# When module is set, just run provisioning - it handles module routing internally
export PROVISIONING_MODULE
$NU "${NU_ARGS[@]}" "$PROVISIONING/core/nulib/$RUNNER" $CMD_ARGS
else
# Only redirect stdin for non-interactive commands (nu command needs interactive stdin)
if [ "${1:-}" = "nu" ]; then
# For interactive mode, start nu with provisioning environment
export PROVISIONING_CONFIG="$PROVISIONING_USER_CONFIG"
# Start nu interactively - it will use the config and env from NU_ARGS
$NU "${NU_ARGS[@]}"
else
# Don't redirect stdin for infrastructure commands - they may need interactive input
# Only redirect for commands we know are safe
case "${1:-}" in
help | h | --help | --info | -i | -v | --version | env | allenv | status | health | list | ls | l | workspace | ws | provider | providers | validate | plugin | plugins | nuinfo | platform | plat)
# Safe commands - can use /dev/null
$NU "${NU_ARGS[@]}" "$PROVISIONING/core/nulib/$RUNNER" $CMD_ARGS </dev/null
;;
*)
# All other commands (create, delete, server, taskserv, etc.) - keep stdin open
# NOTE: PROVISIONING_MODULE is automatically inherited by Nushell from bash environment
$NU "${NU_ARGS[@]}" "$PROVISIONING/core/nulib/$RUNNER" $CMD_ARGS
;;
esac
fi
fi

754
cli/old_provisioning Normal file
View file

@ -0,0 +1,754 @@
#!/usr/bin/env bash
# Info: Script to run Provisioning
# Author: Jesus Perez Lorenzo
# Release: 3.0.11
# Date: 2026-01-14
set +o errexit
set +o pipefail
# Debug: log startup
[ "${PROVISIONING_DEBUG_STARTUP:-false}" = "true" ] && echo "[DEBUG] Wrapper started with args: $@" >&2
export NU=$(type -P nu)
_release() {
grep "^# Release:" "$0" | sed "s/# Release: //g"
}
export PROVISIONING_VERS=$(_release)
set -o allexport
## shellcheck disable=SC1090
[ -n "$PROVISIONING_ENV" ] && [ -r "$PROVISIONING_ENV" ] && source "$PROVISIONING_ENV"
[ -r "../env-provisioning" ] && source ../env-provisioning
[ -r "env-provisioning" ] && source ./env-provisioning
#[ -r ".env" ] && source .env set
# Disable provisioning logo/banner output
export PROVISIONING_NO_TITLES=true
set +o allexport
export PROVISIONING=${PROVISIONING:-/usr/local/provisioning}
PROVIISONING_WKPATH=${PROVIISONING_WKPATH:-/tmp/tmp.}
RUNNER="provisioning"
PROVISIONING_MODULE=""
PROVISIONING_MODULE_TASK=""
# Safe argument handling - use default empty value if unbound
[ "${1:-}" == "" ] && shift
[ -z "$NU" ] || [ "${1:-}" == "install" ] || [ "${1:-}" == "reinstall" ] || [ "${1:-}" == "mode" ] && exec bash $PROVISIONING/core/bin/install_nu.sh $PROVISIONING ${1:-} ${2:-}
[ "${1:-}" == "rmwk" ] && rm -rf "$PROVIISONING_WKPATH"* && echo "$PROVIISONING_WKPATH deleted" && exit
[ "${1:-}" == "-x" ] && debug=-x && export PROVISIONING_DEBUG=true && shift
[ "${1:-}" == "-xm" ] && export PROVISIONING_METADATA=true && shift
[ "${1:-}" == "nu" ] && export PROVISIONING_DEBUG=true
[ "${1:-}" == "--x" ] && set -x && debug=-x && export PROVISIONING_DEBUG=true && shift
[ "${1:-}" == "-i" ] || [ "${2:-}" == "-i" ] && echo "$(basename "$0") $(grep "^# Info:" "$0" | sed "s/# Info: //g") " && exit
[ "${1:-}" == "-v" ] || [ "${1:-}" == "--version" ] || [ "${2:-}" == "-v" ] && _release && exit
# ════════════════════════════════════════════════════════════════════════════════
# FLOW-AWARE TTY COMMAND FILTER
# Manages three execution flows: exit (standalone), pipe (inter-command), continue (Nushell)
# Registry: provisioning/core/cli/tty-commands.conf
# Filter: provisioning/core/cli/tty-filter.sh
# ════════════════════════════════════════════════════════════════════════════════
if [ -f "$PROVISIONING/core/cli/tty-filter.sh" ]; then
# Source filter function
# shellcheck source=/dev/null
source "$PROVISIONING/core/cli/tty-filter.sh"
# Try to filter TTY command (full command line as single string)
# Return codes:
# - filter_tty_command returns 0: flow=continue case handled, continue to Nushell with $TTY_OUTPUT
# - filter_tty_command exits: flow=exit/pipe case completed (already exited)
# - filter returns 1: not a TTY command, continue to normal processing
if filter_tty_command "$@"; then
# Flow=continue: TTY wrapper executed, output in $TTY_OUTPUT, bypass daemon
# $env.PROVISIONING_BYPASS_DAEMON and $env.TTY_OUTPUT available to Nushell
: # Continue to Nushell dispatcher below
fi
fi
CMD_ARGS=$@
# Note: Flag ordering is handled by Nushell's reorder_args function
# which automatically reorders flags before positional arguments.
# Flags can be placed anywhere on the command line.
case "${1:-}" in
# Note: "setup" is now handled by the main provisioning CLI dispatcher
# No special module handling needed
-mod)
PROVISIONING_MODULE=$(echo "$2" | sed 's/ //g' | cut -f1 -d"|")
PROVISIONING_MODULE_TASK=$(echo "$2" | sed 's/ //g' | cut -f2 -d"|")
[ "$PROVISIONING_MODULE" == "$PROVISIONING_MODULE_TASK" ] && PROVISIONING_MODULE_TASK=""
shift 2
CMD_ARGS=$@
[ "${PROVISIONING_DEBUG_STARTUP:-false}" = "true" ] && echo "[DEBUG] -mod detected: MODULE=$PROVISIONING_MODULE, TASK=$PROVISIONING_MODULE_TASK, CMD_ARGS=$CMD_ARGS" >&2
;;
esac
NU_ARGS=""
DEFAULT_CONTEXT_TEMPLATE="default_context.yaml"
case "$(uname | tr '[:upper:]' '[:lower:]')" in
linux)
PROVISIONING_USER_CONFIG="$HOME/.config/provisioning/nushell"
PROVISIONING_CONTEXT_PATH="$HOME/.config/provisioning/$DEFAULT_CONTEXT_TEMPLATE"
PROVISIONING_USER_PLATFORM="$HOME/.config/provisioning/platform"
;;
darwin)
PROVISIONING_USER_CONFIG="$HOME/Library/Application Support/provisioning/nushell"
PROVISIONING_CONTEXT_PATH="$HOME/Library/Application Support/provisioning/$DEFAULT_CONTEXT_TEMPLATE"
PROVISIONING_USER_PLATFORM="$HOME/Library/Application Support/provisioning/platform"
;;
*)
PROVISIONING_USER_CONFIG="$HOME/.config/provisioning/nushell"
PROVISIONING_CONTEXT_PATH="$HOME/.config/provisioning/$DEFAULT_CONTEXT_TEMPLATE"
PROVISIONING_USER_PLATFORM="$HOME/.config/provisioning/platform"
;;
esac
# ════════════════════════════════════════════════════════════════════════════════
# DAEMON ROUTING - Try daemon for all commands (except setup/help/interactive)
# Falls back to traditional handlers if daemon unavailable
# ════════════════════════════════════════════════════════════════════════════════
DAEMON_ENDPOINT="http://127.0.0.1:9091/execute"
# Function to execute command via daemon
execute_via_daemon() {
local cmd="$1"
shift
# Build JSON array of arguments (simple bash)
local args_json="["
local first=1
for arg in "$@"; do
[ $first -eq 0 ] && args_json="$args_json,"
args_json="$args_json\"$(echo "$arg" | sed 's/"/\\"/g')\""
first=0
done
args_json="$args_json]"
# Determine timeout based on command type
# Heavy commands (create, delete, update) get longer timeout
local timeout=0.5
case "$cmd" in
create | delete | update | setup | init) timeout=5 ;;
*) timeout=0.2 ;;
esac
# Make request and extract stdout
curl -s -m $timeout -X POST "$DAEMON_ENDPOINT" \
-H "Content-Type: application/json" \
-d "{\"command\":\"$cmd\",\"args\":$args_json,\"timeout_ms\":30000}" 2>/dev/null |
sed -n 's/.*"stdout":"\(.*\)","execution.*/\1/p' |
sed 's/\\n/\n/g'
}
# Try daemon ONLY for lightweight commands (list, show, status)
# Skip daemon for heavy commands (create, delete, update) because bash wrapper is slow
# ALSO skip daemon for flow=continue commands (need stdin for TTY interaction)
if [ "${PROVISIONING_BYPASS_DAEMON:-}" != "true" ] && ([ "${1:-}" = "server" ] || [ "${1:-}" = "s" ]); then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
# Light command - try daemon
[ -n "${PROVISIONING_DEBUG:-}" ] && [ "${PROVISIONING_DEBUG:-}" = "true" ] && echo "⚡ Attempting daemon execution..." >&2
DAEMON_OUTPUT=$(execute_via_daemon "$@" 2>/dev/null)
if [ -n "$DAEMON_OUTPUT" ]; then
echo "$DAEMON_OUTPUT"
exit 0
fi
[ -n "${PROVISIONING_DEBUG:-}" ] && [ "${PROVISIONING_DEBUG:-}" = "true" ] && echo "⚠️ Daemon unavailable, using traditional handlers..." >&2
fi
# NOTE: Command reordering (server create -> create server) has been removed.
# The Nushell dispatcher in provisioning/core/nulib/main_provisioning/dispatcher.nu
# handles command routing correctly and expects "server create" format.
# The reorder_args function in provisioning script handles any flag reordering needed.
fi
# ════════════════════════════════════════════════════════════════════════════════
# FAST-PATH: Commands that don't need full config loading or platform bootstrap
# These commands use lib_minimal.nu for <100ms execution
# (ONLY REACHED if daemon is not available)
# ═══<E29590><E29590><EFBFBD>════════════════════════════════════════════════════════════════════════════
# Help commands fast-path (uses help_minimal.nu)
# Detects "help" in ANY argument position, not just first
help_category=""
help_found=false
# Check if first arg is empty (no args provided) - treat as help request
if [ -z "${1:-}" ]; then
help_found=true
else
# Loop through all arguments to find help variant and extract category
for arg in "$@"; do
case "$arg" in
help|-h|--help|--helpinfo)
help_found=true
;;
-*)
# Skip flags (like -x, -xm, -i, -v, etc.)
;;
*)
# First non-flag, non-help argument becomes the category
if [ "$help_category" = "" ]; then
help_category="$arg"
fi
;;
esac
done
fi
# Execute help fast-path if help was requested
if [ "$help_found" = true ]; then
# Export LANG explicitly to ensure locale detection works in nu subprocess
export LANG
$NU -n -c "source '$PROVISIONING/core/nulib/help_minimal.nu'; provisioning-help '$help_category' | print" 2>/dev/null
exit $?
fi
# Workspace operations (fast-path)
if [ "${1:-}" = "workspace" ] || [ "${1:-}" = "ws" ]; then
case "${2:-}" in
"list" | "")
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-list | get ok | table" 2>/dev/null
exit $?
;;
"active")
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-active" 2>/dev/null
exit $?
;;
"info")
if [ -n "${3:-}" ]; then
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-info '$3'" 2>/dev/null
else
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; workspace-active | workspace-info \$in" 2>/dev/null
fi
exit $?
;;
esac
# Other workspace commands (switch, register, etc.) fall through to full loading
fi
# Status/Health check (fast-path) - DISABLED to fix dispatcher loop
# Use normal dispatcher path instead of fast-path with lib_minimal.nu
# if [ "$1" = "status" ] || [ "$1" = "health" ]; then
# $NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; status-quick | table" 2>/dev/null
# exit $?
# fi
# Environment display (fast-path)
if [ "${1:-}" = "env" ] || [ "${1:-}" = "allenv" ]; then
$NU -n -c "source '$PROVISIONING/core/nulib/lib_minimal.nu'; env-quick | table" 2>/dev/null
exit $?
fi
# Provider list (lightweight - reads filesystem only, no module loading)
if [ "${1:-}" = "provider" ] || [ "${1:-}" = "providers" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
let provisioning = (\$env.PROVISIONING | default '/usr/local/provisioning')
let providers_base = (\$provisioning | path join 'extensions' | path join 'providers')
if not (\$providers_base | path exists) {
print 'PROVIDERS list: (none found)'
return
}
# Discover all providers from directories
let all_providers = (
ls \$providers_base | where type == 'dir' | each {|prov_dir|
let prov_name = (\$prov_dir.name | path basename)
if \$prov_name != 'prov_lib' {
{name: \$prov_name, type: 'providers', version: '0.0.1'}
} else {
null
}
} | compact
)
if (\$all_providers | length) == 0 {
print 'PROVIDERS list: (none found)'
} else {
print 'PROVIDERS list: '
print ''
\$all_providers | table
}
" 2>/dev/null
exit $?
fi
fi
# Taskserv list (fast-path) - avoid full system load
if [ "${1:-}" = "taskserv" ] || [ "${1:-}" = "task" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
# Direct implementation of taskserv discovery (no dependency loading)
# Taskservs are nested: extensions/taskservs/{category}/{name}/kcl/
let provisioning = (\$env.PROVISIONING | default '/usr/local/provisioning')
let taskservs_base = (\$provisioning | path join 'extensions' | path join 'taskservs')
if not (\$taskservs_base | path exists) {
print '📦 Available Taskservs: (none found)'
return null
}
# Discover all taskservs from nested categories
let all_taskservs = (
ls \$taskservs_base | where type == 'dir' | each {|cat_dir|
let category = (\$cat_dir.name | path basename)
let cat_path = (\$taskservs_base | path join \$category)
if (\$cat_path | path exists) {
ls \$cat_path | where type == 'dir' | each {|ts|
let ts_name = (\$ts.name | path basename)
{task: \$ts_name, mode: \$category, info: ''}
}
} else {
[]
}
} | flatten
)
if (\$all_taskservs | length) == 0 {
print '📦 Available Taskservs: (none found)'
} else {
print '📦 Available Taskservs:'
print ''
\$all_taskservs | each {|ts|
print \$\" • (\$ts.task) [(\$ts.mode)]\"
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Server list (lightweight - reads filesystem only, no config loading)
if [ "${1:-}" = "server" ] || [ "${1:-}" = "s" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
# Extract --infra flag from remaining args
INFRA_FILTER=""
shift
[ "${1:-}" = "list" ] && shift
while [ $# -gt 0 ]; do
case "${1:-}" in
--infra | -i)
INFRA_FILTER="${2:-}"
shift 2
;;
*) shift ;;
esac
done
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print 'No active workspace'
return
}
# Get workspace path from config
let user_config_path = if (\$env.HOME | path exists) {
(
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
} else {
''
}
if not (\$user_config_path | path exists) {
print 'Config not found'
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print 'Workspace not found'
return
}
let ws_path = \$ws.path
let infra_path = (\$ws_path | path join 'infra')
if not (\$infra_path | path exists) {
print 'No infrastructures found'
return
}
# Filter by infrastructure if specified
let infra_filter = \"$INFRA_FILTER\"
# List server definitions from infrastructure (filtered if --infra specified)
let servers = (
ls \$infra_path | where type == 'dir' | each {|infra|
let infra_name = (\$infra.name | path basename)
# Skip if filter is specified and doesn't match
if ((\$infra_filter | is-not-empty) and (\$infra_name != \$infra_filter)) {
[]
} else {
let servers_file = ($infra_path | path join \$infra_name | path join 'defs' | path join 'servers.ncl')
let servers_file_kcl = ($infra_path | path join \$infra_name | path join 'defs' | path join 'servers.k')
if ($servers_file | path exists) {
# Parse the Nickel servers.ncl file to extract server hostnames
let content = (open \$servers_file --raw)
# Extract hostnames from hostname = "..." patterns by splitting on quotes
let hostnames = (
\$content
| split row \"\\n\"
| where {|line| \$line | str contains \"hostname = \\\"\" }
| each {|line|
# Split by quotes to extract hostname value
let parts = (\$line | split row \"\\\"\")
if (\$parts | length) >= 2 {
\$parts | get 1
} else {
\"\"
}
}
| where {|h| (\$h | is-not-empty) }
)
\$hostnames | each {|srv_name|
{
name: \$srv_name
infrastructure: \$infra_name
path: \$servers_file
}
}
} else {
[]
}
}
} | flatten
)
if (\$servers | length) == 0 {
print '📦 Available Servers: (none configured)'
} else {
print '📦 Available Servers:'
print ''
\$servers | each {|srv|
print \$\" • (\$srv.name) [(\$srv.infrastructure)]\"
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Cluster list (lightweight - reads filesystem only)
if [ "${1:-}" = "cluster" ] || [ "${1:-}" = "cl" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print 'No active workspace'
return
}
# Get workspace path from config
let user_config_path = (
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
if not (\$user_config_path | path exists) {
print 'Config not found'
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print 'Workspace not found'
return
}
let ws_path = \$ws.path
# List all clusters from workspace
let clusters = (
if ((\$ws_path | path join '.clusters') | path exists) {
let clusters_path = (\$ws_path | path join '.clusters')
ls \$clusters_path | where type == 'dir' | each {|cl|
let cl_name = (\$cl.name | path basename)
{
name: \$cl_name
path: \$cl.name
}
}
} else {
[]
}
)
if (\$clusters | length) == 0 {
print '🗂️ Available Clusters: (none found)'
} else {
print '🗂️ Available Clusters:'
print ''
\$clusters | each {|cl|
print \$\" • (\$cl.name)\"
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Infra list (lightweight - reads filesystem only)
if [ "${1:-}" = "infra" ] || [ "${1:-}" = "inf" ]; then
if [ "${2:-}" = "list" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print 'No active workspace'
return
}
# Get workspace path from config
let user_config_path = (
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
if not (\$user_config_path | path exists) {
print 'Config not found'
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print 'Workspace not found'
return
}
let ws_path = \$ws.path
let infra_path = (\$ws_path | path join 'infra')
if not (\$infra_path | path exists) {
print '📁 Available Infrastructures: (none configured)'
return
}
# List all infrastructures
let infras = (
ls \$infra_path | where type == 'dir' | each {|inf|
let inf_name = (\$inf.name | path basename)
let inf_full_path = (\$infra_path | path join \$inf_name)
let has_config = ((\$inf_full_path | path join 'settings.k') | path exists)
{
name: \$inf_name
configured: \$has_config
modified: \$inf.modified
}
}
)
if (\$infras | length) == 0 {
print '📁 Available Infrastructures: (none found)'
} else {
print '📁 Available Infrastructures:'
print ''
\$infras | each {|inf|
let status = if \$inf.configured { '✓' } else { '○' }
let output = \" [\" + \$status + \"] \" + \$inf.name
print \$output
} | ignore
}
" 2>/dev/null
exit $?
fi
fi
# Config validation (lightweight - validates config structure without full load)
if [ "${1:-}" = "validate" ]; then
if [ "${2:-}" = "config" ] || [ -z "${2:-}" ]; then
$NU -n -c "
source '$PROVISIONING/core/nulib/lib_minimal.nu'
try {
# Get active workspace
let active_ws = (workspace-active)
if (\$active_ws | is-empty) {
print '❌ Error: No active workspace'
return
}
# Get workspace path from config
let user_config_path = (
\$env.HOME | path join 'Library' | path join 'Application Support' |
path join 'provisioning' | path join 'user_config.yaml'
)
if not (\$user_config_path | path exists) {
print '❌ Error: User config not found at' \$user_config_path
return
}
let config = (open \$user_config_path)
let workspaces = (\$config | get --optional workspaces | default [])
let ws = (\$workspaces | where { \$in.name == \$active_ws } | first)
if (\$ws | is-empty) {
print '❌ Error: Workspace' \$active_ws 'not found in config'
return
}
let ws_path = \$ws.path
# Validate workspace structure
let required_dirs = ['infra', 'config', '.clusters']
let infra_path = (\$ws_path | path join 'infra')
let config_path = (\$ws_path | path join 'config')
let missing_dirs = \$required_dirs | where { not ((\$ws_path | path join \$in) | path exists) }
if (\$missing_dirs | length) > 0 {
print '⚠️ Warning: Missing directories:' (\$missing_dirs | str join ', ')
}
# Validate infrastructures have required files
if (\$infra_path | path exists) {
let infras = (ls \$infra_path | where type == 'dir')
let invalid_infras = (
\$infras | each {|inf|
let inf_name = (\$inf.name | path basename)
let inf_full_path = (\$infra_path | path join \$inf_name)
if not ((\$inf_full_path | path join 'settings.k') | path exists) {
\$inf_name
} else {
null
}
} | compact
)
if (\$invalid_infras | length) > 0 {
print '⚠️ Warning: Infrastructures missing settings.k:' (\$invalid_infras | str join ', ')
}
}
# Validate user config structure
let has_active = ((\$config | get --optional active_workspace) != null)
let has_workspaces = ((\$config | get --optional workspaces) != null)
let has_preferences = ((\$config | get --optional preferences) != null)
if not \$has_active {
print '⚠️ Warning: Missing active_workspace in user config'
}
if not \$has_workspaces {
print '⚠️ Warning: Missing workspaces list in user config'
}
if not \$has_preferences {
print '⚠️ Warning: Missing preferences in user config'
}
# Summary
print ''
print '✓ Configuration validation complete for workspace:' \$active_ws
print ' Path:' \$ws_path
print ' Status: Valid (with warnings, if any listed above)'
} catch {|err|
print '❌ Validation error:' \$err
}
" 2>/dev/null
exit $?
fi
fi
if [ ! -d "$PROVISIONING_USER_CONFIG" ] || [ ! -r "$PROVISIONING_CONTEXT_PATH" ]; then
[ ! -x "$PROVISIONING/core/nulib/provisioning setup" ] && echo "$PROVISIONING/core/nulib/provisioning setup not found" && exit 1
cd "$PROVISIONING/core/nulib"
./"provisioning setup"
echo ""
read -p "Use [enter] to continue or [ctrl-c] to cancel"
fi
[ ! -r "$PROVISIONING_USER_CONFIG/config.nu" ] && echo "$PROVISIONING_USER_CONFIG/config.nu not found" && exit 1
[ ! -r "$PROVISIONING_USER_CONFIG/env.nu" ] && echo "$PROVISIONING_USER_CONFIG/env.nu not found" && exit 1
NU_ARGS=(--config "$PROVISIONING_USER_CONFIG/config.nu" --env-config "$PROVISIONING_USER_CONFIG/env.nu")
export PROVISIONING_ARGS="$CMD_ARGS" NU_ARGS="$NU_ARGS"
#export NU_ARGS=${NU_ARGS//Application Support/Application\\ Support}
# Suppress repetitive config export output during initialization
export PROVISIONING_QUIET_EXPORT="true"
# Export NU_LIB_DIRS so Nushell can find modules during parsing
export NU_LIB_DIRS="$PROVISIONING/core/nulib:/opt/provisioning/core/nulib:/usr/local/provisioning/core/nulib"
# ============================================================================
# DAEMON ROUTING - ENABLED (Phase 3.7: CLI Daemon Integration)
# ============================================================================
# Redesigned daemon with pre-loaded Nushell environment (no CLI callback).
# Routes eligible commands to HTTP daemon for <100ms execution.
# Gracefully falls back to full load if daemon unavailable.
#
# ARCHITECTURE:
# 1. Check daemon health (curl with 5ms timeout)
# 2. Route eligible commands to daemon via HTTP POST
# 3. Fall back to full load if daemon unavailable
# 4. Zero breaking changes (graceful degradation)
#
# PERFORMANCE:
# - With daemon: <100ms for ALL commands
# - Without daemon: ~430ms (normal behavior)
# - Daemon fallback: Automatic, user sees no difference
if [ -n "$PROVISIONING_MODULE" ]; then
if [[ -x $PROVISIONING/core/nulib/$RUNNER\ $PROVISIONING_MODULE ]]; then
$NU "${NU_ARGS[@]}" "$PROVISIONING/core/nulib/$RUNNER $PROVISIONING_MODULE" $CMD_ARGS
else
echo "Error \"$PROVISIONING/core/nulib/$RUNNER $PROVISIONING_MODULE\" not found"
fi
else
# Only redirect stdin for non-interactive commands (nu command needs interactive stdin)
if [ "${1:-}" = "nu" ]; then
# For interactive mode, start nu with provisioning environment
export PROVISIONING_CONFIG="$PROVISIONING_USER_CONFIG"
# Start nu interactively - it will use the config and env from NU_ARGS
$NU "${NU_ARGS[@]}"
else
# Don't redirect stdin for infrastructure commands - they may need interactive input
# Only redirect for commands we know are safe
case "${1:-}" in
help | h | --help | --info | -i | -v | --version | env | allenv | status | health | list | ls | l | workspace | ws | provider | providers | validate | plugin | plugins | nuinfo | platform | plat)
# Safe commands - can use /dev/null
$NU "${NU_ARGS[@]}" "$PROVISIONING/core/nulib/$RUNNER" $CMD_ARGS </dev/null
;;
*)
# All other commands (create, delete, server, taskserv, etc.) - keep stdin open
# NOTE: PROVISIONING_MODULE is automatically inherited by Nushell from bash environment
$NU "${NU_ARGS[@]}" "$PROVISIONING/core/nulib/$RUNNER" $CMD_ARGS
;;
esac
fi
fi

File diff suppressed because it is too large Load diff

28
cli/tty-commands.conf Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Minimalist TTY Command Registry (Nu-based)
# Format: "COMMAND_PATTERN" "DISPATCHER_CALL" "FLOW_TYPE"
# All commands routed through tty-dispatch.sh → Nu functions
# Flow types: "exit" (standalone), "pipe" (inter-command), "continue" (to Nushell)
# ═══════════════════════════════════════════════════════════════════════════════
# Authentication & Setup Commands
# ═══════════════════════════════════════════════════════════════════════════════
# Standalone wizards (flow=exit)
"setup wizard" "core/cli/tty-dispatch.sh setup-wizard exit" "exit"
"auth login" "core/cli/tty-dispatch.sh login exit" "exit"
"auth mfa enroll" "core/cli/tty-dispatch.sh mfa-enroll exit" "exit"
# Pipeline commands (flow=pipe) - output to stdout
"auth get-key" "core/cli/tty-dispatch.sh get-key pipe" "pipe"
# Continue to Nushell (flow=continue) - output captured in $TTY_OUTPUT
"auth integrate" "core/cli/tty-dispatch.sh credential-input continue" "continue"
"secret configure" "core/cli/tty-dispatch.sh secret-configure continue" "continue"
# ═══════════════════════════════════════════════════════════════════════════════
# Future-proofing: Add new commands without modifying tty-filter.sh
# Example:
# "wizard something" "core/cli/tty-dispatch.sh something exit" "exit"
# "get something" "core/cli/tty-dispatch.sh something pipe" "pipe"
# ═══════════════════════════════════════════════════════════════════════════════

86
cli/tty-dispatch.sh Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Universal TTY Command Dispatcher
# Routes TTY commands to Nu functions with proper output handling
# Usage: tty-dispatch.sh <function-name> [flow-type] [args...]
set -euo pipefail
FUNCTION_NAME="${1:-}"
FLOW_TYPE="${2:-exit}"
shift 2 || true
if [[ -z "$FUNCTION_NAME" ]]; then
echo "Error: Function name required" >&2
exit 1
fi
# Find nu binary
NU=$(type -P nu 2>/dev/null || echo "")
if [[ -z "$NU" ]]; then
echo "Error: nu not found in PATH" >&2
exit 1
fi
# Get provisioning root
PROVISIONING="${PROVISIONING:-/usr/local/provisioning}"
# Map function name to Nu function with proper naming conventions
case "$FUNCTION_NAME" in
"setup-wizard")
NU_FUNCTION="run-setup-wizard-interactive"
;;
"login"|"auth-login")
NU_FUNCTION="login-interactive"
;;
"mfa"|"mfa-enroll"|"auth-mfa-enroll")
NU_FUNCTION="mfa-enroll-interactive"
;;
"auth-get-key"|"get-key")
NU_FUNCTION="get-api-key-interactive"
;;
"auth-integrate"|"credential-input")
NU_FUNCTION="get-provider-credentials-interactive"
;;
"secret-configure")
NU_FUNCTION="get-secret-config-interactive"
;;
*)
echo "Error: Unknown function: $FUNCTION_NAME" >&2
exit 1
;;
esac
# Execute Nu function with proper output handling
case "$FLOW_TYPE" in
"exit")
# Standalone: Execute and exit immediately
$NU -n -c "
use '$PROVISIONING/core/nulib/lib_provisioning/plugins/auth.nu' *
use '$PROVISIONING/core/nulib/lib_provisioning/setup/wizard.nu' *
$NU_FUNCTION
"
exit $?
;;
"pipe")
# Pipeline: Output to stdout for piping
$NU -n -c "
use '$PROVISIONING/core/nulib/lib_provisioning/plugins/auth.nu' *
use '$PROVISIONING/core/nulib/lib_provisioning/setup/wizard.nu' *
$NU_FUNCTION
"
exit $?
;;
"continue")
# Continue to Nushell: Output as JSON for $TTY_OUTPUT
$NU -n -c "
use '$PROVISIONING/core/nulib/lib_provisioning/plugins/auth.nu' *
use '$PROVISIONING/core/nulib/lib_provisioning/setup/wizard.nu' *
$NU_FUNCTION | to json
"
exit $?
;;
*)
echo "Error: Unknown flow type: $FLOW_TYPE" >&2
exit 1
;;
esac

137
cli/tty-filter.sh Executable file
View file

@ -0,0 +1,137 @@
#!/bin/bash
# Description: Flow-Aware TTY Command Filter
# Manages three execution flows: exit (standalone), pipe (inter-command), continue (Nushell)
# Arguments: $@ - Command and arguments
# Returns: 0 if TTY command handled with flow=continue (continue to Nushell)
# Exits with wrapper code for flow=exit or flow=pipe
# 1 if not a TTY command (continue to normal processing)
# Output: Exports TTY_OUTPUT and PROVISIONING_BYPASS_DAEMON on flow=continue
# Only apply strict mode when run standalone — sourcing this file must not
# contaminate the calling shell's options (set -e would cause `DAEMON_OUTPUT=$(curl ...)`
# to exit the parent script with curl's non-zero exit code instead of falling through).
[[ "${BASH_SOURCE[0]}" == "${0}" ]] && set -euo pipefail
# Description: Check if command matches TTY pattern and manage flow
# Arguments: $@ - Full command line
# Returns: 0 for flow=continue (don't exit), non-zero for error/not-matched
# Exits for flow=exit or flow=pipe (calls exit)
# Output: Executes wrapper or exports environment
filter_tty_command() {
local cmd="$*"
local registry_file="${PROVISIONING:-}/core/cli/tty-commands.conf"
# Validate registry exists
if [[ ! -f "$registry_file" ]]; then
return 1
fi
# Read registry using separate file descriptor to preserve stdin
while IFS= read -r line <&3 || [[ -n "$line" ]]; do
# Skip comments and separators
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*═ ]] && continue
[[ -z "$line" ]] && continue
# Parse three-field format: "PATTERN" "WRAPPER" "FLOW_TYPE"
if [[ "$line" =~ ^\"([^\"]+)\"[[:space:]]+\"([^\"]+)\"[[:space:]]+\"([^\"]+)\" ]]; then
local pattern="${BASH_REMATCH[1]}"
local wrapper="${BASH_REMATCH[2]}"
local flow_type="${BASH_REMATCH[3]}"
# Check if command starts with pattern (prefix match)
# This allows commands with additional arguments like "auth integrate --provider azure"
if [[ "$cmd" == "$pattern"* ]]; then
local wrapper_path="${PROVISIONING}/${wrapper}"
# Validate wrapper exists and is executable
if [[ ! -x "$wrapper_path" ]]; then
echo "Warning: TTY wrapper not found or not executable: $wrapper_path" >&2
return 1
fi
# Extract arguments after pattern
# Pattern may be multi-word (e.g., "setup platform")
# Count pattern words and skip them from arguments
local pattern_words=($pattern)
local pattern_count=${#pattern_words[@]}
local wrapper_args=()
# Shift arguments to skip pattern words
for ((i=pattern_count; i<$#; i++)); do
wrapper_args+=("${@:i+1:1}")
done
# ═══════════════════════════════════════════════════════════
# FLOW TYPE: exit (standalone TTY)
# Execute wrapper and exit immediately
# Never reaches Nushell dispatcher
# ═══════════════════════════════════════════════════════════
if [[ "$flow_type" == "exit" ]]; then
if [[ ${#wrapper_args[@]} -gt 0 ]]; then
bash "$wrapper_path" "${wrapper_args[@]}"
else
bash "$wrapper_path"
fi
exit $?
# ═══════════════════════════════════════════════════════════
# FLOW TYPE: pipe (inter-command piping)
# Execute wrapper, output to stdout, exit
# Allows piping to next command in pipeline
# ═══════════════════════════════════════════════════════════
elif [[ "$flow_type" == "pipe" ]]; then
if [[ ${#wrapper_args[@]} -gt 0 ]]; then
bash "$wrapper_path" "${wrapper_args[@]}"
else
bash "$wrapper_path"
fi
exit $?
# ═══════════════════════════════════════════════════════════
# FLOW TYPE: continue (same-command Nushell processing)
# Execute wrapper, capture output, continue to Nushell
# Nushell receives $env.TTY_OUTPUT and original args
# ═══════════════════════════════════════════════════════════
elif [[ "$flow_type" == "continue" ]]; then
# Execute wrapper and capture output
local tty_output
if [[ ${#wrapper_args[@]} -gt 0 ]]; then
tty_output=$(bash "$wrapper_path" "${wrapper_args[@]}" 2>&1) || {
local exit_code=$?
echo "Error: TTY wrapper failed with code $exit_code" >&2
echo "$tty_output" >&2
exit $exit_code
}
else
tty_output=$(bash "$wrapper_path" 2>&1) || {
local exit_code=$?
echo "Error: TTY wrapper failed with code $exit_code" >&2
echo "$tty_output" >&2
exit $exit_code
}
fi
# Export output for Nushell scripts to access
export TTY_OUTPUT="$tty_output"
export PROVISIONING_BYPASS_DAEMON="true"
export TTY_WRAPPER_EXECUTED="true"
# Return 0 WITHOUT exiting - allows continuation to Nushell
return 0
else
echo "Warning: Unknown flow type '$flow_type' for pattern '$pattern'" >&2
return 1
fi
fi
fi
done 3< "$registry_file"
return 1
}
# Only run filter if called directly (not sourced)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
filter_tty_command "$@"
fi

View file

@ -32,11 +32,26 @@ def install_from_library [
$"($defs.server.hostname) (_ansi default_dimmed)install(_ansi reset) " +
$"(_ansi purple_bold)from library(_ansi reset)"
)
let taskservs_path = (get-taskservs-path)
( run_taskserv $defs
($taskservs_path | path join $defs.taskserv.name | path join $defs.taskserv_profile)
($wk_server | path join $defs.taskserv.name)
)
let base = (get-taskservs-path)
let name = $defs.taskserv.name
# Resolve the script directory with profile → mode fallback chain:
# 1. Exact profile name (e.g. "k0sctl")
# 2. "taskserv" (canonical mode dir — was "default/" pre-migration)
# 3. Error with actionable message
let profile = $defs.taskserv_profile
let by_profile = ($base | path join $name | path join $profile)
let by_taskserv = ($base | path join $name | path join "taskserv")
let lib_path = if ($by_profile | path exists) {
$by_profile
} else if ($by_taskserv | path exists) {
if $profile != "default" {
_print $"(_ansi yellow)⚠ profile '($profile)' not found for ($name), falling back to taskserv/(_ansi reset)"
}
$by_taskserv
} else {
error make { msg: $"No script directory for component '($name)': tried ($by_profile) and ($by_taskserv)" }
}
( run_taskserv $defs $lib_path ($wk_server | path join $name) )
}
export def on_taskservs [

View file

@ -4,7 +4,7 @@ export def provisioning_options [
source: string
] {
let provisioning_name = (get-provisioning-name)
let provisioning_path = (get-base-path)
let provisioning_path = (get-config-base-path)
let provisioning_url = (get-provisioning-url)
(

View file

@ -78,10 +78,31 @@ export def run_taskserv_library [
let nickel_temp = ($taskserv_env_path | path join "nickel"| path join (mktemp --tmpdir-path $taskserv_env_path --suffix ".ncl" | path basename))
let wk_format = if (get-provisioning-wk-format) == "json" { "json" } else { "yaml" }
# Resolve floating_ip name → actual IP from provisioning state so taskserv
# templates can use server.floating_ip_address without hardcoding.
let fip_name = ($defs.server | get -o floating_ip | default "")
let resolved_fip_address = if ($fip_name | is-not-empty) {
let state_path = ($defs.settings.infra_path | path dirname | path dirname | path join ".provisioning-state.json")
if ($state_path | path exists) {
let fips = (open $state_path | get -o bootstrap.floating_ips | default {})
# FIP names are stored with hyphens converted to underscores as keys
# e.g. "librecloud-fip-sgoyol-ingress" → key "sgoyol_ingress" (strip prefix, replace hyphens)
let fip_key = ($fip_name | str replace --regex '^librecloud-fip-' '' | str replace --all '-' '_')
$fips | get -o $fip_key | default {} | get -o ip | default ""
} else { "" }
} else { "" }
let server_ctx = if ($resolved_fip_address | is-not-empty) {
$defs.server | merge { floating_ip_address: $resolved_fip_address }
} else {
$defs.server
}
let wk_data = { # providers: $defs.settings.providers,
defs: $defs.settings.data,
pos: $defs.pos,
server: $defs.server
server: $server_ctx
}
if $wk_format == "json" {
$wk_data | to json | save --force $wk_vars

View file

@ -80,8 +80,8 @@ export def format_timestamp [timestamp: int]: nothing -> string {
# Retry function with exponential backoff (no try-catch)
export def retry_with_backoff [closure: closure, max_attempts: int = 3, initial_delay: int = 1]: nothing -> any {
let mut attempts = 0
let mut delay = $initial_delay
mut attempts = 0
mut delay = $initial_delay
loop {
let result = (do { $closure | call } | complete)

314
nulib/commands-registry.ncl Normal file
View file

@ -0,0 +1,314 @@
# Command Registry Default Values
let { make_command, .. } = import "schemas/commands_registry/defaults.ncl" in
let cmd_reg_schema = import "schemas/commands_registry/schema.ncl" in
{
commands = [
make_command {
command = "help",
aliases = ["h", "-h", "--help"],
uses_cache = true,
help_category = "infrastructure",
description = "Show help for commands",
},
make_command {
command = "platform",
aliases = ["plat", "p"],
uses_cache = true,
help_category = "platform",
description = "Manage platform services",
},
make_command {
command = "guide",
aliases = ["guides", "howto"],
uses_cache = true,
help_category = "guides",
description = "Show guides and tutorials",
},
make_command {
command = "shortcuts",
aliases = ["sc"],
uses_cache = true,
requires_args = true,
help_category = "guides",
description = "Show command shortcuts",
},
make_command {
command = "quickstart",
aliases = ["quick"],
uses_cache = true,
requires_args = true,
help_category = "guides",
description = "Quick start guide",
},
make_command {
command = "from-scratch",
aliases = ["scratch"],
uses_cache = true,
requires_args = true,
help_category = "guides",
description = "Start from scratch guide",
},
make_command {
command = "customize",
aliases = ["custom"],
uses_cache = true,
requires_args = true,
help_category = "guides",
description = "Customization guide",
},
make_command {
command = "bootstrap",
aliases = ["bstrap"],
help_category = "infrastructure",
description = "L1 Hetzner resource bootstrap (network, firewall, SSH key, Floating IPs)",
},
make_command {
command = "fip",
aliases = ["floating-ip"],
help_category = "infrastructure",
description = "Floating IP management (list, show, assign, unassign, protection)",
},
make_command {
command = "volume",
aliases = ["vol"],
help_category = "infrastructure",
description = "Volume management (list, create, attach, detach, delete)",
},
make_command {
command = "server",
aliases = ["s"],
requires_daemon = true,
requires_services = true,
requires_args = true,
help_category = "infrastructure",
description = "Server management",
},
make_command {
command = "ssh",
requires_args = true,
help_category = "infrastructure",
description = "SSH shortcut: connect to a server by hostname (e.g. prvng ssh sgoyol-1)",
},
make_command {
command = "taskserv",
aliases = ["task", "t"],
requires_args = true,
help_category = "infrastructure",
description = "Task server management",
},
make_command {
command = "component",
aliases = ["c", "comp", "cl"],
requires_args = true,
help_category = "infrastructure",
description = "Component management — list, show, and status for workspace component instances",
},
make_command {
command = "extension",
aliases = ["e", "ext"],
requires_args = true,
help_category = "infrastructure",
description = "Extension catalog — browse extensions/components/ definitions and metadata",
},
make_command {
command = "create",
aliases = ["new"],
requires_args = true,
requires_daemon = true,
requires_services = true,
help_category = "infrastructure",
description = "Create resources (server, taskserv, cluster)",
},
make_command {
command = "delete",
aliases = ["d"],
requires_args = true,
help_category = "infrastructure",
description = "Delete resources (server, taskserv, cluster)",
},
make_command {
command = "workspace",
aliases = ["ws"],
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "workspace",
description = "Workspace management",
},
make_command {
command = "validate",
aliases = ["val"],
uses_cache = true,
requires_args = true,
help_category = "config",
description = "Validate configuration",
},
make_command {
command = "config",
uses_cache = true,
requires_args = true,
help_category = "setup",
description = "Configuration management",
},
make_command {
command = "env",
uses_cache = true,
requires_args = true,
help_category = "config",
description = "Environment configuration",
},
make_command {
command = "alias",
aliases = ["a", "al"],
uses_cache = true,
help_category = "utils",
description = "Show command aliases — alias list (al) displays the full shortcut table",
},
make_command {
command = "show",
uses_cache = true,
requires_args = true,
help_category = "config",
description = "Show configuration",
},
make_command {
command = "setup",
aliases = ["st"],
uses_cache = true,
help_category = "setup",
description = "Initial setup",
},
make_command {
command = "state",
aliases = ["st"],
uses_cache = false,
requires_args = true,
help_category = "state",
description = "Workspace provisioning state management",
},
make_command {
command = "job",
aliases = ["j"],
requires_args = true,
uses_cache = false,
help_category = "orchestration",
description = "Orchestrator job management (list, status, monitor, submit)",
},
make_command {
command = "workflow",
aliases = ["w", "wflow"],
requires_args = true,
uses_cache = false,
help_category = "orchestration",
description = "Workspace workflow management — WorkflowDef lifecycle (list, show, run, validate, status)",
},
make_command {
command = "batch",
aliases = ["b", "bat"],
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "orchestration",
description = "Batch operations",
},
make_command {
command = "orchestrator",
aliases = ["o", "orch"],
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "orchestration",
description = "Orchestrator management",
},
make_command {
command = "module",
aliases = ["mod"],
uses_cache = true,
requires_args = true,
help_category = "development",
description = "Module management",
},
make_command {
command = "layer",
aliases = ["lyr"],
uses_cache = true,
requires_args = true,
help_category = "development",
description = "Layer management",
},
make_command {
command = "discover",
aliases = ["disc"],
uses_cache = true,
requires_args = true,
help_category = "development",
description = "Discover modules",
},
make_command {
command = "status",
uses_cache = true,
requires_args = true,
help_category = "diagnostics",
description = "Show status",
},
make_command {
command = "health",
uses_cache = true,
requires_args = true,
help_category = "diagnostics",
description = "Health check",
},
make_command {
command = "diagnostics",
aliases = ["diag"],
uses_cache = true,
requires_args = true,
help_category = "diagnostics",
description = "Run diagnostics",
},
make_command {
command = "build",
aliases = ["bd"],
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "build",
description = "Build operations",
},
make_command {
command = "auth",
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "authentication",
description = "Authentication management",
},
make_command {
command = "login",
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "authentication",
description = "Login",
},
make_command {
command = "integrations",
aliases = ["int"],
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "integrations",
description = "Integration management",
},
make_command {
command = "vm",
requires_daemon = true,
uses_cache = true,
requires_args = true,
help_category = "vm",
description = "VM management",
},
],
}

312
nulib/components/mod.nu Normal file
View file

@ -0,0 +1,312 @@
#!/usr/bin/env nu
# Component management module — list, show, status for extensions/components.
#
# Two perspectives per component:
# extension — what exists in extensions/components/{name}/ (metadata, modes, contract)
# workspace — how it's instantiated in infra/{ws}/components/{name}.ncl
#
# Ontology data (FSM state, edges) is read via ontoref when available (defensive).
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval-soft, default-ncl-paths]
# Resolve the extensions/components/ base path.
def _comp-ext-base []: nothing -> string {
let from_env = ($env.PROVISIONING_COMPONENTS_PATH? | default "")
if ($from_env | is-not-empty) and ($from_env | path exists) { return $from_env }
let prov = ($env.PROVISIONING? | default "")
if ($prov | is-not-empty) {
let p = ($prov | path join "extensions" | path join "components")
if ($p | path exists) { return $p }
}
""
}
# Resolve the workspace root for a given workspace name.
# Checks PROVISIONING_KLOUD_PATH env, then walks known workspace directories.
def _ws-root [workspace: string]: nothing -> string {
if ($workspace | is-empty) { return "" }
let from_env = ($env.PROVISIONING_KLOUD_PATH? | default "")
if ($from_env | is-not-empty) and ($from_env | path basename) == $workspace {
return $from_env
}
let prov = ($env.PROVISIONING? | default "")
if ($prov | is-not-empty) {
let ws_root = ($prov | path dirname | path join "workspaces" | path join $workspace)
if ($ws_root | path exists) { return $ws_root }
}
""
}
# Export a Nickel file to a record. Returns null on failure.
# Uses default-ncl-paths to match the daemon's cache key derivation.
def _ncl-export [file_path: string]: nothing -> any {
let ws_root = ($file_path | path dirname | path dirname | path dirname)
ncl-eval-soft $file_path (default-ncl-paths $ws_root) null
}
# Read FSM dimension for a component from state.ncl via ontoref or raw NCL export.
def _read-fsm-state [name: string, ws_root: string]: nothing -> record {
let dim_id = $"($name)-status"
# Try ontoref first (richer output)
let onto_result = (do {
^ontoref describe state $dim_id --fmt json --workspace $ws_root
} | complete)
if $onto_result.exit_code == 0 {
let parsed = (do { $onto_result.stdout | from json } | complete)
if $parsed.exit_code == 0 { return $parsed.stdout }
}
# Fallback: export state.ncl and filter
let state_path = ($ws_root | path join ".ontology" | path join "state.ncl")
if not ($state_path | path exists) { return {} }
let prov = ($env.PROVISIONING? | default "")
let state_data = (ncl-eval-soft $state_path (default-ncl-paths $ws_root) {})
if ($state_data | is-empty) { return {} }
let dims = ($state_data | get -o dimensions | default [])
$dims | where {|d| ($d | get -o id | default "") == $dim_id } | get 0? | default {}
}
# Read ontology node and edges for a component from core.ncl.
def _read-onto-node [name: string, ws_root: string]: nothing -> record {
let core_path = ($ws_root | path join ".ontology" | path join "core.ncl")
if not ($core_path | path exists) { return { node: null, edges_from: [], edges_to: [] } }
let prov = ($env.PROVISIONING? | default "")
let data = (ncl-eval-soft $core_path (default-ncl-paths $ws_root) null)
if $data == null { return { node: null, edges_from: [], edges_to: [] } }
let nodes = ($data | get -o nodes | default [])
let edges = ($data | get -o edges | default [])
let node = ($nodes | where {|n| ($n | get -o id | default "") == $name } | get 0? | default null)
let edges_from = ($edges | where {|e| ($e | get -o from | default "") == $name })
let edges_to = ($edges | where {|e| ($e | get -o to | default "") == $name })
{ node: $node, edges_from: $edges_from, edges_to: $edges_to }
}
# List all components from extensions/components/ with optional mode filter and workspace state.
export def component-list [mode: string, workspace: string]: nothing -> nothing {
let base = (_comp-ext-base)
if ($base | is-empty) or not ($base | path exists) {
print "❌ extensions/components/ not found. Set PROVISIONING env var."
return
}
let ws_root = (_ws-root $workspace)
let show_state = ($ws_root | is-not-empty)
mut rows = []
for item in (ls $base | where type == "dir") {
let name = ($item.name | path basename)
let meta_p = ($item.name | path join "metadata.ncl")
let meta = if ($meta_p | path exists) { _ncl-export $meta_p } else { null }
let modes = if $meta != null { $meta | get -o modes | default ["taskserv"] } else { ["taskserv"] }
let version = if $meta != null { $meta | get -o version | default "" } else { "" }
let desc = if $meta != null { $meta | get -o description | default "" } else { "" }
# Mode filter
if ($mode | is-not-empty) and ($mode not-in $modes) { continue }
let state = if $show_state {
let dim = (_read-fsm-state $name $ws_root)
if ($dim | is-empty) { "—" } else {
let cur = ($dim | get -o current_state | default "—")
let des = ($dim | get -o desired_state | default "")
if ($des | is-not-empty) and $cur != $des { $"($cur) → ($des)" } else { $cur }
}
} else { "—" }
$rows = ($rows | append {
name: $name
mode: ($modes | str join "·")
state: $state
version: $version
})
}
if ($rows | is-empty) {
print "No components found."
return
}
let header = if $show_state { $"Components [workspace: ($workspace)]" } else { "Components [extension catalog]" }
print $header
print "────────────────────────────────────────────────────────────"
$rows | table
}
# Show full details for a named component.
export def component-show [name: string, workspace: string, ext_only: bool]: nothing -> nothing {
let base = (_comp-ext-base)
let ext_dir = ($base | path join $name)
if not ($ext_dir | path exists) {
print $"❌ Component '($name)' not found in extensions/components/"
return
}
let meta_p = ($ext_dir | path join "metadata.ncl")
let meta = if ($meta_p | path exists) { _ncl-export $meta_p } else { null }
# Extension section
let modes = if $meta != null { $meta | get -o modes | default ["taskserv"] } else { ["taskserv"] }
let version = if $meta != null { $meta | get -o version | default "" } else { "" }
let desc = if $meta != null { $meta | get -o description | default "" } else { "" }
let tags = if $meta != null { $meta | get -o tags | default [] | str join " · " } else { "" }
# Defaults (requires/provides/operations from nickel/defaults.ncl)
let defaults_p = ($ext_dir | path join "nickel" | path join "defaults.ncl")
let defaults = if ($defaults_p | path exists) { _ncl-export $defaults_p } else { null }
let def_rec = if $defaults != null { $defaults | get -o $name | default {} } else { {} }
let requires = ($def_rec | get -o requires | default {})
let provides = ($def_rec | get -o provides | default {})
let operations = ($def_rec | get -o operations | default {})
print $"┌─ ($name | str upcase) ─────────────────────────────────"
print $"│ ($desc)"
print $"├────────────────────────────────────────────────────────"
let modes_str = ($modes | str join " · ")
print $"│ VERSION ($version)"
print $"│ MODES ($modes_str)"
if ($tags | is-not-empty) { print $"│ TAGS ($tags)" }
# REQUIRES
let req_storage = ($requires | get -o storage | default null)
let req_ports = ($requires | get -o ports | default [])
let req_creds = ($requires | get -o credentials | default [])
if $req_storage != null or ($req_ports | is-not-empty) or ($req_creds | is-not-empty) {
print "├─── REQUIRES ───────────────────────────────────────────"
if $req_storage != null {
let persist_label = if ($req_storage.persistent? | default false) { "persistent" } else { "ephemeral" }
let stor_size = ($req_storage.size? | default "?")
print $"│ storage ($stor_size) ($persist_label)"
}
for p in $req_ports {
let pport = ($p.port? | default 0 | into string)
let pproto = ($p.protocol? | default "TCP")
let pexpose = ($p.exposure? | default "internal")
print $"│ port ($pport)/($pproto) \(($pexpose)\)"
}
if ($req_creds | is-not-empty) {
let creds_str = ($req_creds | str join " · ")
print $"│ creds ($creds_str)"
}
}
# PROVIDES
let prov_svc = ($provides | get -o service | default "")
let prov_port = ($provides | get -o port | default null)
let prov_dbs = ($provides | get -o databases | default [])
if ($prov_svc | is-not-empty) or $prov_port != null or ($prov_dbs | is-not-empty) {
print "├─── PROVIDES ───────────────────────────────────────────"
if ($prov_svc | is-not-empty) and $prov_port != null {
print $"│ service ($prov_svc):($prov_port)"
} else if ($prov_svc | is-not-empty) {
print $"│ service ($prov_svc)"
}
if ($prov_dbs | is-not-empty) {
let dbs_str = ($prov_dbs | str join " · ")
print $"│ databases ($dbs_str)"
}
}
# OPERATIONS
let ops_enabled = ($operations | transpose k v | where v == true | each {|r| $r.k })
if ($ops_enabled | is-not-empty) {
let ops_str = ($ops_enabled | str join " · ")
print "├─── OPERATIONS ─────────────────────────────────────────"
print $"│ ($ops_str)"
}
if not $ext_only and ($workspace | is-not-empty) {
let ws_root = (_ws-root $workspace)
if ($ws_root | is-not-empty) {
# Workspace instance
let comp_p = ($ws_root | path join "infra" | path join $workspace | path join "components" | path join $"($name).ncl")
let comp_data = if ($comp_p | path exists) { _ncl-export $comp_p } else { null }
let inst = if $comp_data != null { $comp_data | get -o $name | default {} } else { {} }
let inst_mode = ($inst | get -o mode | default "")
let inst_ns = ($inst | get -o namespace | default "")
let inst_tgt = ($inst | get -o target | default "")
print "├─── WORKSPACE INSTANCE ─────────────────────────────────"
if ($inst_mode | is-not-empty) { print $"│ mode ($inst_mode)" }
if ($inst_ns | is-not-empty) { print $"│ namespace ($inst_ns)" }
if ($inst_tgt | is-not-empty) { print $"│ target ($inst_tgt)" }
# FSM state
let dim = (_read-fsm-state $name $ws_root)
if not ($dim | is-empty) {
let cur = ($dim | get -o current_state | default "—")
let des = ($dim | get -o desired_state | default "")
let blk = ($dim | get -o transitions | default [] | get 0? | default {} | get -o blocker | default "")
let blk_short = ($blk | str substring 0..80)
print "├─── STATE ───────────────────────────────────────────"
print $"│ current ($cur)"
if ($des | is-not-empty) { print $"│ desired ($des)" }
if ($blk | is-not-empty) { print $"│ blocker ($blk_short)" }
}
# Ontology
let onto = (_read-onto-node $name $ws_root)
if $onto.node != null {
let node = $onto.node
let node_lvl = ($node.level? | default "?")
let node_pole = ($node.pole? | default "?")
print "├─── ONTOLOGY ────────────────────────────────────────"
print $"│ node ($name) \(($node_lvl) / ($node_pole)\)"
let arts = ($node | get -o artifact_paths | default [])
if ($arts | is-not-empty) {
let arts_str = ($arts | str join " · ")
print $"│ artifacts ($arts_str)"
}
let adrs = ($node | get -o adrs | default [])
if ($adrs | is-not-empty) {
let adrs_str = ($adrs | str join " · ")
print $"│ adrs ($adrs_str)"
}
if ($onto.edges_from | is-not-empty) {
let consumers = ($onto.edges_from | each {|e|
let eto = ($e | get -o to | default "?")
let ekind = ($e | get -o kind | default "")
$"($eto) \(($ekind)\)"
} | str join " · ")
print $"│ used-by ($consumers)"
}
if ($onto.edges_to | is-not-empty) {
let uses = ($onto.edges_to | each {|e|
let efrom = ($e | get -o from | default "?")
let ekind = ($e | get -o kind | default "")
$"($efrom) \(($ekind)\)"
} | str join " · ")
print $"│ uses ($uses)"
}
}
}
}
print "└────────────────────────────────────────────────────────"
}
# Show only FSM state for a component.
export def component-status [name: string, workspace: string]: nothing -> nothing {
if ($workspace | is-empty) {
print "❌ --workspace required for status"
return
}
let ws_root = (_ws-root $workspace)
if ($ws_root | is-empty) {
print $"❌ Workspace '($workspace)' not found"
return
}
let dim = (_read-fsm-state $name $ws_root)
if ($dim | is-empty) {
print $"No FSM dimension found for '($name)-status' in ($workspace)"
return
}
let cur = ($dim | get -o current_state | default "—")
let des = ($dim | get -o desired_state | default "—")
let blk = ($dim | get -o transitions | default [] | get 0? | default {} | get -o blocker | default "")
let cat = ($dim | get -o transitions | default [] | get 0? | default {} | get -o catalyst | default "")
print $"($name) — FSM state [($workspace)]"
print $" current: ($cur)"
print $" desired: ($des)"
if ($blk | is-not-empty) { print $" blocker: ($blk)" }
if ($cat | is-not-empty) { print $" catalyst: ($cat)" }
}

View file

@ -65,9 +65,16 @@ export-env {
# Just set it to a reasonable default
$env.PROVISIONING_CORE = "/usr/local/provisioning/core"
}
$env.PROVISIONING_PROVIDERS_PATH = ($env.PROVISIONING | path join "extensions" | path join "providers")
$env.PROVISIONING_TASKSERVS_PATH = ($env.PROVISIONING | path join "extensions" | path join "taskservs")
$env.PROVISIONING_CLUSTERS_PATH = ($env.PROVISIONING | path join "extensions" | path join "clusters")
$env.PROVISIONING_PROVIDERS_PATH = ($env.PROVISIONING | path join "extensions" | path join "providers")
$env.PROVISIONING_COMPONENTS_PATH = ($env.PROVISIONING | path join "extensions" | path join "components")
# Keep for backward compat — points to taskservs/ if it exists, falls back to components/
let _ts_path = ($env.PROVISIONING | path join "extensions" | path join "taskservs")
$env.PROVISIONING_TASKSERVS_PATH = if ($env.PROVISIONING_COMPONENTS_PATH | path exists) {
$env.PROVISIONING_COMPONENTS_PATH
} else {
$_ts_path
}
$env.PROVISIONING_CLUSTERS_PATH = ($env.PROVISIONING | path join "extensions" | path join "clusters")
$env.PROVISIONING_RESOURCES = ($env.PROVISIONING | path join "resources" )
$env.PROVISIONING_NOTIFY_ICON = ($env.PROVISIONING_RESOURCES | path join "images"| path join "cloudnative.png")
@ -124,7 +131,6 @@ export-env {
$env.PROVISIONING_KEYS_PATH = (config-get "paths.files.keys" ".keys.ncl" --config $config)
$env.PROVISIONING_USE_nickel = if (^bash -c "type -P nickel" | is-not-empty) { true } else { false }
$env.PROVISIONING_USE_NICKEL_PLUGIN = if ( (version).installed_plugins | str contains "nickel" ) { true } else { false }
#$env.PROVISIONING_J2_PARSER = ($env.PROVISIONING_$TOOLS_PATH | path join "parsetemplate.py")
#$env.PROVISIONING_J2_PARSER = (^bash -c "type -P tera")
@ -211,6 +217,7 @@ export-env {
# Nickel Module Path Configuration
# Set up NICKEL_IMPORT_PATH to help Nickel resolve modules when running from different directories
$env.NICKEL_IMPORT_PATH = ($env.NICKEL_IMPORT_PATH? | default [] | append [
$env.PROVISIONING
($env.PROVISIONING | path join "nickel")
($env.PROVISIONING_PROVIDERS_PATH)
$env.PWD

View file

@ -156,13 +156,14 @@ def provisioning-help [category?: string = ""] {
"concepts" | "concept" => "concepts"
"guides" | "guide" | "howto" => "guides"
"integrations" | "integration" | "int" => "integrations"
"build" | "bi" | "build-image" => "build"
_ => "unknown"
})
if $result == "unknown" {
print $"❌ Unknown help category: \"($category)\"\n"
print "Available help categories: infrastructure, orchestration, development, workspace, setup, platform,"
print "authentication, mfa, plugins, utilities, tools, vm, diagnostics, concepts, guides, integrations"
print "authentication, mfa, plugins, utilities, tools, vm, diagnostics, concepts, guides, integrations, build"
return ""
}
@ -183,6 +184,7 @@ def provisioning-help [category?: string = ""] {
"concepts" => (help-concepts)
"guides" => (help-guides)
"integrations" => (help-integrations)
"build" => (help-build)
_ => (help-main)
}
}
@ -238,6 +240,7 @@ def help-main [] {
["💡", "concepts", "", $concepts_desc],
["📖", "guides", "[guide]", $guides_desc],
["🌐", "integrations", "[int]", $int_desc],
["📦", "build", "[bi]", "Role image build, state, and watch"],
]
let categories_table = (format-categories $rows)
@ -439,13 +442,56 @@ def help-workspace [] {
# Platform help
def help-platform [] {
let title = (get-help-string "help-platform-title")
let intro = (get-help-string "help-platform-intro")
let more_info = (get-help-string "help-more-info")
(
(ansi red) + (ansi bo) + ($title) + (ansi rst) + "\n\n" +
($intro) + "\n\n" +
($more_info) + "\n"
(ansi red) + (ansi bo) + "🖥️ PLATFORM SERVICES" + (ansi rst) + "\n\n" +
(ansi green) + (ansi bo) + "[Control Center]" + (ansi rst) + " " + (ansi cyan) + (ansi bo) + "🌐 Web UI + Policy Engine" + (ansi rst) + "\n" +
" " + (ansi blue) + "control-center server" + (ansi rst) + "\t\t\t - Start Cedar policy engine " + (ansi cyan) + "--port 8080" + (ansi rst) + "\n" +
" " + (ansi blue) + "control-center policy validate" + (ansi rst) + "\t - Validate Cedar policies\n" +
" " + (ansi blue) + "control-center policy test" + (ansi rst) + "\t\t - Test policies with data\n" +
" " + (ansi blue) + "control-center compliance soc2" + (ansi rst) + "\t - SOC2 compliance check\n" +
" " + (ansi blue) + "control-center compliance hipaa" + (ansi rst) + "\t - HIPAA compliance check\n\n" +
(ansi cyan) + (ansi bo) + " 🎨 Features:" + (ansi rst) + "\n" +
" • " + (ansi green) + "Web-based UI" + (ansi rst) + "\t - WASM-powered control center interface\n" +
" • " + (ansi green) + "Policy Engine" + (ansi rst) + "\t - Cedar policy evaluation and versioning\n" +
" • " + (ansi green) + "Compliance" + (ansi rst) + "\t - SOC2 Type II and HIPAA validation\n" +
" • " + (ansi green) + "Security" + (ansi rst) + "\t\t - JWT auth, MFA, RBAC, anomaly detection\n" +
" • " + (ansi green) + "Audit Trail" + (ansi rst) + "\t - Complete compliance audit logging\n\n" +
(ansi green) + (ansi bo) + "[Orchestrator]" + (ansi rst) + " Hybrid Rust/Nushell Coordination\n" +
" " + (ansi blue) + "orchestrator start" + (ansi rst) + " - Start orchestrator [--background]\n" +
" " + (ansi blue) + "orchestrator stop" + (ansi rst) + " - Stop orchestrator\n" +
" " + (ansi blue) + "orchestrator status" + (ansi rst) + " - Check if running\n" +
" " + (ansi blue) + "orchestrator health" + (ansi rst) + " - Health check with diagnostics\n" +
" " + (ansi blue) + "orchestrator logs" + (ansi rst) + " - View logs [--follow]\n\n" +
(ansi green) + (ansi bo) + "[MCP Server]" + (ansi rst) + " AI-Assisted DevOps Integration\n" +
" " + (ansi blue) + "mcp-server start" + (ansi rst) + " - Start MCP server [--debug]\n" +
" " + (ansi blue) + "mcp-server status" + (ansi rst) + " - Check server status\n\n" +
(ansi cyan) + (ansi bo) + " 🤖 Features:" + (ansi rst) + "\n" +
" • " + (ansi green) + "AI-Powered Parsing" + (ansi rst) + " - Natural language to infrastructure\n" +
" • " + (ansi green) + "Multi-Provider" + (ansi rst) + "\t - AWS, UpCloud, Local support\n" +
" • " + (ansi green) + "Ultra-Fast" + (ansi rst) + "\t - Microsecond latency, 1000x faster than Python\n" +
" • " + (ansi green) + "Type Safe" + (ansi rst) + "\t\t - Compile-time guarantees with zero runtime errors\n\n" +
(ansi green) + (ansi bo) + "🌐 REST API ENDPOINTS" + (ansi rst) + "\n\n" +
(ansi yellow) + "Control Center" + (ansi rst) + " - " + (ansi d) + "http://localhost:8080" + (ansi rst) + "\n" +
" • POST /policies/evaluate - Evaluate policy decisions\n" +
" • GET /policies - List all policies\n" +
" • GET /compliance/soc2 - SOC2 compliance check\n" +
" • GET /anomalies - List detected anomalies\n\n" +
(ansi yellow) + "Orchestrator" + (ansi rst) + " - " + (ansi d) + "http://localhost:8080" + (ansi rst) + "\n" +
" • GET /health - Health check\n" +
" • GET /tasks - List all tasks\n" +
" • POST /workflows/servers/create - Server workflow\n" +
" • POST /workflows/batch/submit - Batch workflow\n\n" +
(ansi d) + "💡 Control Center provides a " + (ansi cyan) + (ansi bo) + "web-based UI" + (ansi rst) + (ansi d) + " for managing policies!\n" +
" Access at: " + (ansi cyan) + "http://localhost:8080" + (ansi rst) + (ansi d) + " after starting the server\n" +
" Example: provisioning control-center server --port 8080" + (ansi rst) + "\n"
)
}
@ -569,6 +615,49 @@ def help-integrations [] {
)
}
# Build help — role image management
def help-build [] {
(
(ansi yellow) + (ansi bo) + "🏗️ BUILD — Role Image Management" + (ansi rst) + "\n\n" +
(ansi d) + "Pre-built provider snapshots (nixos-generators → Hetzner snapshot).\n" +
"Snapshot IDs and freshness tracked in ~/.config/provisioning/images/.\n" +
"Server creation runs a pre-flight check before rendering templates." + (ansi rst) + "\n\n" +
(ansi green) + (ansi bo) + "[Image Lifecycle]" + (ansi rst) + "\n" +
" " + (ansi blue) + "build image create <role>" + (ansi rst) + " - Build snapshot for role, save state\n" +
" Options: --infra <path> --check --provider <p>\n" +
" " + (ansi blue) + "build image list" + (ansi rst) + " - Show all role states (provider, snapshot_id, fresh)\n" +
" Options: --provider <p>\n" +
" " + (ansi blue) + "build image update <role>" + (ansi rst) + " - Delete stale snapshot and rebuild\n" +
" Options: --infra <path> --provider <p> --check\n" +
" " + (ansi blue) + "build image delete <role>" + (ansi rst) + " - Remove snapshot from provider + local state\n" +
" Options: --provider <p> --yes\n\n" +
(ansi green) + (ansi bo) + "[Monitoring]" + (ansi rst) + "\n" +
" " + (ansi blue) + "build image watch" + (ansi rst) + " - Poll freshness of all role images (loop)\n" +
" Options: --interval <min> --auto-build --notify-only\n" +
" --provider <p> --infra <path>\n\n" +
(ansi green) + (ansi bo) + "[Shortcuts]" + (ansi rst) + "\n" +
" " + (ansi d) + "b, build" + (ansi rst) + " → build domain\n" +
" " + (ansi d) + "bi, build-image" + (ansi rst) + " → build image\n\n" +
(ansi green) + (ansi bo) + "[Examples]" + (ansi rst) + "\n" +
" provisioning build image list\n" +
" provisioning build image create cp --infra workspaces/librecloud_hetzner/infra/wuji --check\n" +
" provisioning build image create cp --infra workspaces/librecloud_hetzner/infra/wuji\n" +
" provisioning build image update worker --infra workspaces/librecloud_hetzner/infra/wuji\n" +
" provisioning build image delete storage --yes\n" +
" provisioning build image watch --interval 30 --auto-build\n\n" +
(ansi green) + (ansi bo) + "[State Files]" + (ansi rst) + "\n" +
" Location: ~/.config/provisioning/images/<provider>-<role>.ncl\n" +
" Schema: provisioning/schemas/infrastructure/images/\n" +
" Workspace roles: workspaces/librecloud_hetzner/infra/wuji/images.ncl\n"
)
}
# Main entry point
def main [...args: string] {
let category = if ($args | length) > 0 { ($args | get 0) } else { "" }

165
nulib/images/create.nu Normal file
View file

@ -0,0 +1,165 @@
# Image create — render build template, execute, capture snapshot ID, persist state.
use ./state.nu *
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval]
# Load the ImageRole definition from the workspace images.ncl for a given role name.
def load-image-role [infra: string, role: string]: nothing -> record {
let images_ncl = ($infra | path join "images.ncl")
if not ($images_ncl | path exists) {
error make { msg: $"images.ncl not found at ($images_ncl)" }
}
let data = (ncl-eval $images_ncl [])
let roles = ($data | get image_roles? | default {})
let role_def = ($roles | get -o $role)
if ($role_def | is-empty) {
error make { msg: $"Role '($role)' not defined in ($images_ncl)" }
}
$role_def
}
# Build template context and render via tera plugin.
def render-build-template [role_def: record, infra: string, check: bool]: nothing -> string {
let tera_loaded = (plugin list | where name == "tera" | length) > 0
if not $tera_loaded { plugin use tera }
let provider = ($role_def | get provider? | default "hetzner")
let tpl_name = ($role_def | get template_name? | default "hetzner_build_image.j2")
let tpl_path = ($env.PROVISIONING | path join "extensions" | path join "providers"
| path join $provider | path join "templates" | path join $tpl_name)
if not ($tpl_path | path exists) {
error make { msg: $"Build template not found: ($tpl_path)" }
}
# Calculate flake directory: go up 2 levels from infra/wuji to workspace root, then add nixos
let infra_expanded = ($infra | path expand)
let workspace_root = ($infra_expanded | path dirname | path dirname)
let flake_dir = ($workspace_root | path join "nixos")
let ctx = {
image_role: $role_def,
ssh_key: ($role_def | get ssh_key? | default ""),
location: ($role_def | get location? | default "nbg1"),
flake_dir: $flake_dir,
now: ((date now) | format date "%Y-%m-%dT%H:%M:%SZ"),
provisioning_version: ($env.PROVISIONING_VERSION? | default "0.0.0"),
check: $check,
}
$ctx | tera-render $tpl_path
}
# Parse the SNAPSHOT_ID=<id> line from build script stdout.
def extract-snapshot-id [output: string]: nothing -> string {
let line = ($output | lines | find "SNAPSHOT_ID=" | first?)
if ($line | is-empty) {
error make { msg: "Build script did not emit SNAPSHOT_ID=<id>" }
}
$line | str replace "SNAPSHOT_ID=" "" | str trim
}
export def image-create [
role: string
--infra: string = ""
--check
] {
let infra_path = if ($infra | is-empty) {
let ws = ($env.PROVISIONING_WORKSPACE? | default "")
if ($ws | is-empty) {
error make { msg: "Specify --infra <path> or set PROVISIONING_WORKSPACE" }
}
$ws | path join "infra"
} else {
let expanded = ($infra | path expand)
# Detect if we're in a project subdirectory and path was duplicated
# E.g., ran from /project/workspaces with --infra workspaces/... → /project/workspaces/workspaces/...
if ($expanded | str contains "workspaces/workspaces") or ($expanded | str contains "infra/infra") {
let cwd = (pwd)
let infra_parts = ($infra | split row "/")
let first_part = ($infra_parts | get 0)
# If we're in a subdirectory that matches the first part of --infra, strip it
if ($cwd | str contains $first_part) {
let adjusted = ($infra_parts | skip 1 | str join "/")
let adjusted_path = ($adjusted | path expand)
if ($adjusted_path | path exists) {
$adjusted_path
} else {
error make {
msg: $"Path duplication detected in: ($expanded)\n\nYou appear to be in a subdirectory. Either:\n 1. Run from project root: cd ($env.HOME)/project-provisioning\n 2. Use absolute path: --infra ($env.HOME)/project-provisioning/workspaces/...\n 3. Use relative from current dir: --infra librecloud_hetzner/infra/wuji"
}
}
} else {
$expanded
}
} else {
$expanded
}
}
let role_def = (load-image-role $infra_path $role)
let provider = ($role_def | get provider? | default "hetzner")
print $"Building image role '($role)' for provider '($provider)'"
if $check {
let script = (render-build-template $role_def $infra_path true)
print "── [check mode] rendered build script ──"
print $script
print "── no snapshot created ──"
return
}
let script = (render-build-template $role_def $infra_path false)
let tmp_dir = ($env.TMPDIR? | default "/tmp")
let tmp_path = ($tmp_dir | path join $"build_image_($provider)_($role).sh")
$script | save --force $tmp_path
^chmod +x $tmp_path
print $"Executing build script: ($tmp_path)"
print ""
# Execute script - redirect output to log file for visibility
let tmp_log = ($tmp_dir | path join $"build_image_($provider)_($role).log")
# Run bash script via shell, capturing output to log file
# Don't use Nushell's external command error handling - let shell handle it
^sh -c $"bash -x ($tmp_path) >($tmp_log) 2>&1 || true"
# ALWAYS print build output, even if bash failed
if ($tmp_log | path exists) {
print ""
print "=== Build Output ==="
print (open $tmp_log)
print ""
}
# Check if script had any error (look for error: in output)
if ($tmp_log | path exists) {
let log_content = (open $tmp_log)
if ($log_content | str contains "error:") {
print "❌ BUILD FAILED - see output above for details"
exit 1
}
}
let snapshot_id = (extract-snapshot-id (open $tmp_log))
print $"Snapshot created: ($snapshot_id)"
let os_base = ($role_def | get os_base? | default "debian-12")
let labels = ($role_def | get labels? | default {})
image-state-write $provider $role {
provider: $provider,
role: $role,
snapshot_id: $snapshot_id,
built_at: ((date now) | format date "%Y-%m-%dT%H:%M:%SZ"),
last_used: null,
os_base: $os_base,
labels: $labels,
}
print $"State saved: (image-state-path $provider $role)"
}

37
nulib/images/delete.nu Normal file
View file

@ -0,0 +1,37 @@
# Image delete — remove Hetzner snapshot and clear local state file.
use ./state.nu *
export def image-delete [
role: string
--provider: string = "hetzner"
--yes
] {
let state = (image-state-read $provider $role)
if $state.snapshot_id == "SNAPSHOT_PENDING" {
print $"Role '($role)' has no snapshot to delete."
return
}
if not $yes {
print $"About to delete snapshot ($state.snapshot_id) for role '($provider)/($role)'"
let answer = (input "Confirm? [y/N] ")
if ($answer | str downcase | str trim) != "y" {
print "Aborted."
return
}
}
let result = (^hcloud image delete $state.snapshot_id | complete)
if $result.exit_code != 0 {
error make { msg: $"hcloud image delete failed: ($result.stderr)" }
}
let path = (image-state-path $provider $role)
if ($path | path exists) {
rm $path
}
print $"Deleted snapshot ($state.snapshot_id) and removed state for '($provider)/($role)'."
}

27
nulib/images/list.nu Normal file
View file

@ -0,0 +1,27 @@
# Image list — display current state of all role image snapshots.
use ./state.nu *
export def image-list [--provider: string = ""]: nothing -> list<record> {
let states = (image-state-list --provider $provider)
if ($states | length) == 0 {
print "No image role states found."
print "Build one with: provisioning build image create <role> --infra <path>"
return []
}
let rows = ($states | each {|s|
let fresh = (do {
image-state-is-fresh $s.provider $s.role
} catch { false })
{
provider: $s.provider,
role: $s.role,
snapshot_id: $s.snapshot_id,
built_at: ($s.built_at? | default "—"),
fresh: $fresh,
os_base: ($s.os_base? | default "—"),
}
})
$rows | table
$rows
}

109
nulib/images/state.nu Normal file
View file

@ -0,0 +1,109 @@
# Image state management — read/write role image state from ~/.config/provisioning/images/
use ../lib_provisioning/utils/nickel_processor.nu [ncl-eval-soft]
export def image-state-path [provider: string, role: string]: nothing -> string {
let dir = ($env.HOME | path join ".config" | path join "provisioning" | path join "images")
$dir | path join $"($provider)-($role).ncl"
}
export def image-state-dir []: nothing -> string {
$env.HOME | path join ".config" | path join "provisioning" | path join "images"
}
# Read state file. Returns a record with ImageRoleState fields.
# If the file does not exist, returns a pending-state record.
export def image-state-read [provider: string, role: string]: nothing -> record {
let path = (image-state-path $provider $role)
if not ($path | path exists) {
return {
provider: $provider,
role: $role,
snapshot_id: "SNAPSHOT_PENDING",
built_at: null,
last_used: null,
os_base: "unknown",
labels: {},
}
}
let result = (ncl-eval-soft $path [] (error make { msg: $"Failed to parse image state ($path)" }))
$result
}
# Write state file as a Nickel record literal.
export def image-state-write [provider: string, role: string, state: record]: nothing -> nothing {
let dir = (image-state-dir)
let path = (image-state-path $provider $role)
if not ($dir | path exists) {
^mkdir -p $dir
}
let built_at_val = if ($state.built_at? | is-empty) { "null" } else { $"\"($state.built_at)\"" }
let last_used_val = if ($state.last_used? | is-empty) { "null" } else { $"\"($state.last_used)\"" }
let labels_str = (
$state.labels?
| default {}
| items {|k, v| $" ($k) = \"($v)\"," }
| str join "\n"
)
let content = $"
\{
provider = \"($state.provider)\",
role = \"($state.role)\",
snapshot_id = \"($state.snapshot_id)\",
built_at = ($built_at_val),
last_used = ($last_used_val),
os_base = \"($state.os_base | default "unknown")\",
labels = \{
($labels_str)
\},
\}
" | str trim
$content | save --force $path
}
# List state files. Optionally filter by provider.
export def image-state-list [--provider: string = ""]: nothing -> list<record> {
let dir = (image-state-dir)
if not ($dir | path exists) {
return []
}
let files = (ls $dir | where name =~ '\.ncl$' | get name)
let states = ($files | each {|f|
ncl-eval-soft $f [] null
} | where { $in != null })
if ($provider | is-empty) {
$states
} else {
$states | where provider == $provider
}
}
# Returns true if the snapshot exists and is within freshness_days of built_at.
export def image-state-is-fresh [provider: string, role: string]: nothing -> bool {
let state = (image-state-read $provider $role)
if $state.snapshot_id == "SNAPSHOT_PENDING" { return false }
if ($state.built_at | is-empty) { return false }
let freshness_days = 30
let built = ($state.built_at | into datetime)
let age_days = ((date now) - $built | into duration | $in / 1day)
$age_days <= $freshness_days
}
# Update only the snapshot_id and built_at fields in an existing state file.
export def image-state-set-snapshot [provider: string, role: string, snapshot_id: string]: nothing -> nothing {
let existing = (image-state-read $provider $role)
let updated = ($existing | merge {
snapshot_id: $snapshot_id,
built_at: ((date now) | format date "%Y-%m-%dT%H:%M:%SZ"),
})
image-state-write $provider $role $updated
}
# Touch last_used timestamp for the given role state.
export def image-state-touch-used [provider: string, role: string]: nothing -> nothing {
let existing = (image-state-read $provider $role)
let updated = ($existing | merge {
last_used: ((date now) | format date "%Y-%m-%dT%H:%M:%SZ"),
})
image-state-write $provider $role $updated
}

22
nulib/images/update.nu Normal file
View file

@ -0,0 +1,22 @@
# Image update — delete old snapshot then rebuild role image.
use ./state.nu *
use ./delete.nu *
use ./create.nu *
export def image-update [
role: string
--provider: string = "hetzner"
--infra: string = ""
--check
] {
let state = (image-state-read $provider $role)
if $state.snapshot_id != "SNAPSHOT_PENDING" {
print $"Removing stale snapshot ($state.snapshot_id) for '($provider)/($role)'..."
image-delete $role --provider $provider --yes
} else {
print $"No existing snapshot — proceeding with fresh build."
}
image-create $role --infra $infra --check=$check
}

49
nulib/images/watch.nu Normal file
View file

@ -0,0 +1,49 @@
# Image watch — periodic freshness monitor for role image snapshots.
use ./state.nu *
use ./create.nu *
# Poll all role image states every N minutes and report stale snapshots.
export def image-watch [
--interval: int = 60
--auto-build
--notify-only
--provider: string = ""
--infra: string = ""
] {
print $"Image watch started (interval: ($interval)m, auto-build: ($auto_build))"
print "Press Ctrl-C to stop."
print ""
loop {
let states = (image-state-list --provider $provider)
let now_str = ((date now) | format date "%Y-%m-%dT%H:%M:%SZ")
print $"[($now_str)] Checking ($states | length) role image(s)..."
for state in $states {
let fresh = (do {
image-state-is-fresh $state.provider $state.role
} catch { false })
if $state.snapshot_id == "SNAPSHOT_PENDING" {
print $"[PENDING] ($state.provider)/($state.role) — no snapshot built"
} else if not $fresh {
let built = ($state.built_at? | default "unknown")
print $"[STALE] ($state.provider)/($state.role) — last built: ($built) snapshot: ($state.snapshot_id)"
if $auto_build and not $notify_only {
print $" → auto-building ($state.role)..."
do {
image-create $state.role --infra $infra
} catch { |e|
print $" ✗ build failed: ($e.msg)"
}
}
} else {
print $"[OK] ($state.provider)/($state.role) — snapshot: ($state.snapshot_id)"
}
}
print ""
sleep ($interval * 60sec)
}
}

View file

@ -96,15 +96,33 @@ export def workspace-info [name: string] {
# Guard: Workspace not found
if ($ws | is-empty) {
return (ok {name: $name, path: "", exists: false})
return (ok {name: $name, path: "", exists: false, default_infra: "", infrastructures: []})
}
# Pure transformation
# Collect infra dirs with server counts
let infra_root = ($ws.path | path join 'infra')
let infrastructures = if ($infra_root | path exists) {
ls $infra_root
| where type == 'dir'
| each {|inf|
let inf_name = ($inf.name | path basename)
let sf_direct = ($infra_root | path join $inf_name | path join 'servers.ncl')
let sf_defs = ($infra_root | path join $inf_name | path join 'defs' | path join 'servers.ncl')
let sf = if ($sf_direct | path exists) { $sf_direct } else { $sf_defs }
let server_count = if ($sf | path exists) {
open $sf --raw | split row "\n" | where {|l| $l =~ 'hostname\s*=\s*"' } | length
} else { 0 }
{ name: $inf_name, servers: $server_count }
}
} else { [] }
ok {
name: $ws.name
path: $ws.path
exists: true
last_used: ($ws | get --optional last_used | default "Never")
default_infra: ($ws | get --optional default_infra | default "")
infrastructures: $infrastructures
}
}
@ -112,9 +130,9 @@ export def workspace-info [name: string] {
# Rule 1: Explicit types, Rule 4: Early returns
# Result: {ok: record, err: null} on success; {ok: null, err: message} on error
export def status-quick [] {
# Guard: HTTP check with optional operator (no try-catch)
# Optional operator ? suppresses network errors and returns null
let orch_health = (http get --max-time 2sec "http://localhost:9090/health"?)
# Guard: HTTP check with do/complete pattern (no try-catch)
let health_result = (do { http get --max-time 2sec "http://localhost:9090/health" } | complete)
let orch_health = if ($health_result.exit_code == 0) { $health_result.stdout } else { null }
let orch_status = if ($orch_health != null) { "running" } else { "stopped" }
# Guard: Get active workspace safely

View file

@ -1,7 +1,8 @@
export-env {
use ../config/accessor.nu *
use ../lib_provisioning/cmd/lib.nu check_env
use ../utils/logging.nu [is-debug-enabled]
use ./lib.nu check_env
check_env
$env.PROVISIONING_DEBUG = if (is-debug-enabled) {
true

View file

@ -214,7 +214,7 @@ export def "env create" [
_ => "config.user.toml.example"
}
let base_path = (get-base-path)
let base_path = (get-config-base-path)
let source_template = ($base_path | path join $template_path)
if not ($source_template | path exists) {

View file

@ -2,6 +2,7 @@
# Made for prepare and postrun
use ../config/accessor.nu *
use ../utils/ui.nu *
use ../utils/init.nu [get-workspace-path get-provisioning-infra-path]
use ../sops *
export def log_debug [
@ -51,7 +52,7 @@ export def sops_cmd [
let sops_key = (find-sops-key)
if ($sops_key | is-empty) {
$env.CURRENT_INFRA_PATH = ((get-provisioning-infra-path) | path join (get-workspace-path | path basename))
use ../../../sops_env.nu
use ../../sops_env.nu
}
#use sops/lib.nu on_sops
if $error_exit {

View file

@ -0,0 +1,14 @@
# Configuration Accessor - Minimal
# Workaround for Nushell 0.110.0 parser bug
export def get-config [] {
{}
}
export def config-get [path: string, default_value: any = null] {
$default_value
}
export def get-full-config [] {
{}
}

View file

@ -1,3 +1,83 @@
# Module: Core Configuration Accessor
# Purpose: Provides primary configuration access functions: get-config, config-get, config-has, and configuration section getters.
# Dependencies: loader.nu for load-provisioning-config
# Configuration Accessor - Core
# Provides high-level configuration access methods
# Imports temporarily disabled due to Nushell parser bug
# use ../context_manager.nu *
# Define locally to avoid import cycle
def load-provisioning-config [workspace_path: string = "", environment: string = "default", --debug, --no-cache] {
{}
}
# Get current configuration
export def get-config [--force-reload] {
load-provisioning-config
}
# Get configuration value using dot notation path
export def config-get [
path: string
default_value: any = null
--config: any = null
] {
let cfg = if ($config != null) {
$config
} else {
load-provisioning-config
}
$default_value
}
# Check if a configuration path exists
export def config-has [path: string] {
false
}
# Set configuration value
export def config-set [path: string, value: any] {
# No-op
}
# Merge configurations
export def config-merge [configs: list] {
{}
}
# Get environment configuration
export def get-environment-config [
environment: string = "default"
--config: any = null
--debug
--validate
--skip-env-detection
] {
if $debug {
print $"Getting config for environment: $environment"
}
load-provisioning-config
}
# Get full configuration
export def get-full-config [
--debug
--validate
--skip-env-detection
] {
if $debug {
print "Getting full configuration"
}
load-provisioning-config
}
# Check if config value is set
export def is-config-set [path: string] {
false
}
# Get configuration section
export def config-section [section: string] {
{}
}

View file

@ -1,3 +1,77 @@
# Module: Configuration Accessor Functions
# Purpose: Provides 60+ specific accessor functions for individual configuration paths (debug, sops, paths, output, etc.)
# Dependencies: accessor_core for get-config and config-get
# Get provisioning URL
export def get-provisioning-url [] : nothing -> string {
$env.PROVISIONING_URL? | default "https://provisioning.systems"
}
# Get components library path (extensions/components — flat structure post-migration).
# Resolution order: PROVISIONING_COMPONENTS_PATH env → paths.components config →
# derived as sibling of PROVISIONING_TASKSERVS_PATH → PROVISIONING/extensions/components
export def get-components-path [] : nothing -> string {
let from_env = ($env.PROVISIONING_COMPONENTS_PATH? | default "")
if ($from_env | is-not-empty) and ($from_env | path exists) { return $from_env }
let configured = (config-get "paths.components" "")
if ($configured | is-not-empty) and ($configured | path exists) { return $configured }
# Derive from PROVISIONING root
let prov = ($env.PROVISIONING? | default "")
if ($prov | is-not-empty) {
let derived = ($prov | path join "extensions" | path join "components")
if ($derived | path exists) { return $derived }
}
""
}
# Get taskservs library path.
# Post-migration: extensions/components/ is the primary source.
# Falls back to extensions/taskservs/ for non-migrated workspaces.
# Resolution order: PROVISIONING_TASKSERVS_PATH (if exists on disk) →
# components path → PROVISIONING/extensions/taskservs
export def get-taskservs-path [] : nothing -> string {
# Env var set by env.nu — already points to components/ post-migration
let from_env = ($env.PROVISIONING_TASKSERVS_PATH? | default "")
if ($from_env | is-not-empty) and ($from_env | path exists) { return $from_env }
# components/ explicit
let components = (get-components-path)
if ($components | is-not-empty) and ($components | path exists) { return $components }
# Legacy taskservs/
let prov = ($env.PROVISIONING? | default "")
if ($prov | is-not-empty) {
let ts = ($prov | path join "extensions" | path join "taskservs")
if ($ts | path exists) { return $ts }
}
$from_env
}
# Get run-taskservs path (workspace-side generated taskserv files)
export def get-run-taskservs-path [] : nothing -> string {
config-get "paths.run_taskservs" "taskservs"
}
# Get workspace vars format: "json" or "yaml"
export def get-provisioning-wk-format [] : nothing -> string {
$env.PROVISIONING_WK_FORMAT? | default (config-get "output.format" "yaml")
}
# Whether to use Nickel for taskserv templating — true by default, disable with PROVISIONING_USE_NICKEL=false
export def get-use-nickel [] : nothing -> bool {
($env.PROVISIONING_USE_NICKEL? | default "true") != "false"
}
# Path to SOPS keys file (for secrets decryption)
export def get-keys-path [] : nothing -> string {
config-get "paths.files.keys" ".keys.k"
}
# Path to the vars file for the current taskserv run (set by run.nu make_cmd_env_temp)
export def get-provisioning-vars [] : nothing -> string {
$env.PROVISIONING_VARS? | default ""
}
# Path to the working env directory for the current taskserv (set by run.nu make_cmd_env_temp)
export def get-provisioning-wk-env-path [] : nothing -> string {
$env.PROVISIONING_WK_ENV_PATH? | default ""
}

View file

@ -1,9 +1,61 @@
# Module: Configuration Accessor System
# Purpose: Provides unified access to configuration values with core functions and 60+ specific accessors.
# Dependencies: loader for load-provisioning-config
# Reads platform service endpoints from deployment-mode.ncl via the platform target module.
# All other paths return their default values.
# Core accessor functions
export use ./core.nu *
use ../../platform/target.nu [load-deployment-mode]
# Specific configuration getter/setter functions
# Build a service URL from a service config record (server.{host,port} or endpoint field).
def service-cfg-url [cfg: record]: nothing -> string {
let explicit = ($cfg | get -o endpoint | default "")
if ($explicit | is-not-empty) { return $explicit }
let srv = ($cfg | get -o server)
if $srv == null { return "" }
let host = ($srv | get -o host | default "127.0.0.1")
let port = ($srv | get -o port | default 0)
if $port == 0 { "" } else { $"http://($host):($port)" }
}
# Resolve known platform URL paths from deployment-mode.ncl.
# Returns null for unrecognised paths so config-get falls back to default_value.
def resolve-platform-path [deployment: record, path: string]: nothing -> any {
match $path {
"platform.orchestrator.url" => {
let svc = ($deployment | get -o orchestrator)
if $svc == null { null } else { let u = (service-cfg-url $svc); if ($u | is-empty) { null } else { $u } }
}
"platform.orchestrator.endpoint" => {
let svc = ($deployment | get -o orchestrator)
if $svc == null { null } else { let u = (service-cfg-url $svc); if ($u | is-empty) { null } else { $"($u)/health" } }
}
"platform.control_center.url" => {
let svc = ($deployment | get -o control_center)
if $svc == null { null } else { let u = (service-cfg-url $svc); if ($u | is-empty) { null } else { $u } }
}
"platform.kms.url" => {
let svc = ($deployment | get -o vault_service)
if $svc == null { null } else { let u = (service-cfg-url $svc); if ($u | is-empty) { null } else { $u } }
}
"platform.kms.endpoint" => {
let svc = ($deployment | get -o vault_service)
if $svc == null { null } else { let u = (service-cfg-url $svc); if ($u | is-empty) { null } else { $"($u)/health" } }
}
_ => { null }
}
}
export def get-config []: nothing -> record {
load-deployment-mode
}
export def config-get [path: string, default_value: any = null, --config: any = null]: nothing -> any {
let deployment = if ($config != null) { $config } else { load-deployment-mode }
let val = (resolve-platform-path $deployment $path)
if $val == null { $default_value } else { $val }
}
export def get-full-config []: nothing -> record {
load-deployment-mode
}
# Import specific functions only
export use ./functions.nu *

View file

@ -1,10 +1,10 @@
# Configuration Accessor Functions
# Generated from Nickel schema: /Users/Akasha/project-provisioning/provisioning/schemas/config/settings/main.ncl
# Generated from Nickel schema: {$env.PROVISIONING}/schemas/config/settings/main.ncl
# DO NOT EDIT - Generated by accessor_generator.nu v1.0.0
#
# Generator version: 1.0.0
# Generated: 2026-01-13T13:49:23Z
# Schema: /Users/Akasha/project-provisioning/provisioning/schemas/config/settings/main.ncl
# Schema: {$env.PROVISIONING}/schemas/config/settings/main.ncl
# Schema Hash: e129e50bba0128e066412eb63b12f6fd0f955d43133e1826dd5dc9405b8a9647
# Accessor Count: 76
#

View file

@ -4,10 +4,11 @@
use ./core.nu *
use ./metadata.nu *
use ./config_manager.nu *
use ./nickel.nu *
use ./sops.nu *
use ./final.nu *
# Avoid importing all modules - use only what's needed
# use ./config_manager.nu *
# use ./nickel.nu *
# use ./sops.nu *
# use ./final.nu *
# ============================================================================
# Data Operations: Clear, List, Warm, Validate

View file

@ -1,364 +1,158 @@
# Module: Cache Core System
# Purpose: Core caching system for configuration, compiled templates, and decrypted secrets.
# Dependencies: metadata, config_manager, nickel, sops, final
# Cache Core — reads from the shared plugin cache directory.
# Written by ncl-sync daemon; read by this module and nu_plugin_nickel.
# Single writer principle: Nu NEVER writes to the cache dir directly.
# Configuration Cache System - Core Operations
# Provides fundamental cache lookup, write, validation, and cleanup operations
# Follows Nushell 0.109.0+ guidelines: explicit types, early returns, pure functions
use ./metadata.nu *
# Helper: Get cache base directory
def get-cache-base-dir [] {
# Check if a directory has workspace markers.
def is-ws-dir [path: string]: nothing -> bool {
if ($path | is-empty) or (not ($path | path exists)) { return false }
let has_infra = ($path | path join "infra" | path exists)
let has_config = ($path | path join "config" "provisioning.ncl" | path exists)
let has_onto = ($path | path join ".ontology" | path exists)
$has_infra or $has_config or $has_onto
}
# Walk up from PWD to find workspace root (recursive).
def find-ws-up [path: string]: nothing -> string {
if ($path | is-empty) or $path == "/" { return "" }
if (is-ws-dir $path) { return $path }
let parent = ($path | path dirname)
if $parent == $path { return "" }
find-ws-up $parent
}
# Global cache directory (shared across workspaces, for files under $PROVISIONING).
def get-global-cache-dir []: nothing -> string {
let home = ($env.HOME? | default "~" | path expand)
$home | path join ".provisioning" "cache" "config"
}
# Helper: Get cache file path for a given type and key
def get-cache-file-path [
cache_type: string # "nickel", "sops", "final", "provider", "platform"
cache_key: string # Unique identifier (usually a hash)
] {
let base = (get-cache-base-dir)
let type_dir = match $cache_type {
"nickel" => "nickel"
"sops" => "sops"
"final" => "workspaces"
"provider" => "providers"
"platform" => "platform"
_ => "other"
}
$base | path join $type_dir $cache_key
}
# Helper: Get metadata file path
def get-cache-meta-path [cache_file: string] {
$"($cache_file).meta"
}
# Helper: Create cache directory structure if not exists
def ensure-cache-dirs [] {
let base = (get-cache-base-dir)
for dir in ["nickel" "sops" "workspaces" "providers" "platform" "index"] {
let dir_path = ($base | path join $dir)
if not ($dir_path | path exists) {
mkdir $dir_path
}
}
}
# Helper: Compute SHA256 hash
def compute-hash [content: string] {
let hash_result = (do {
$content | ^openssl dgst -sha256 -hex
} | complete)
if $hash_result.exit_code == 0 {
($hash_result.stdout | str trim | split column " " | get column1 | get 0)
let host_info = (do { sys host } | complete)
let is_mac = if $host_info.exit_code == 0 {
($host_info.stdout | get name | str downcase | str contains "darwin")
or ($host_info.stdout | get name | str downcase | str contains "macos")
} else {
($content | hash md5 | str substring 0..16)
($home | path join "Library" | path exists)
}
}
# Helper: Get file modification time
def get-file-mtime [file_path: string] {
if ($file_path | path exists) {
let file_dir = ($file_path | path dirname)
let file_name = ($file_path | path basename)
let file_list = (ls $file_dir | where name == $file_name)
if ($file_list | length) > 0 {
let file_info = ($file_list | get 0)
($file_info.modified | into int)
} else {
-1
}
if $is_mac {
$home | path join "Library" "Caches" "provisioning" "config-cache"
} else {
-1
$home | path join ".cache" "provisioning" "config-cache"
}
}
# ============================================================================
# PUBLIC API: Cache Operations
# ============================================================================
# Resolve cache directory FOR A SPECIFIC FILE. Priority:
# 1. $NCL_CACHE_DIR (explicit override, for CI/tests)
# 2. File under $PROVISIONING → global cache (extensions, schemas — shared)
# 3. File under a workspace (walk up from file path) → <ws>/.ncl-cache/
# 4. Fallback: global cache
#
# Must match resolve_cache_dir_for_file() in ncl-sync + plugin.
def get-cache-dir-for-file [file_path: string]: nothing -> string {
if ($env.NCL_CACHE_DIR? | is-not-empty) {
return $env.NCL_CACHE_DIR
}
# File under $PROVISIONING → global cache
let prov = ($env.PROVISIONING? | default "")
if ($prov | is-not-empty) and ($file_path | str starts-with $prov) {
return (get-global-cache-dir)
}
# File under a workspace → workspace-local cache
let ws = (find-ws-up ($file_path | path dirname))
if ($ws | is-not-empty) {
return ($ws | path join ".ncl-cache")
}
get-global-cache-dir
}
# Lookup cache entry with TTL + mtime validation
# Legacy helper (CWD-based) — kept for backwards compat in code paths that don't have
# the file path at hand. Prefer get-cache-dir-for-file.
def get-cache-base-dir []: nothing -> string {
if ($env.NCL_CACHE_DIR? | is-not-empty) { return $env.NCL_CACHE_DIR }
let ws = (find-ws-up $env.PWD)
if ($ws | is-not-empty) { return ($ws | path join ".ncl-cache") }
get-global-cache-dir
}
# Lookup a cache entry by pre-computed key.
# Only "nickel" type is backed by the shared plugin cache.
# Returns: { valid: bool, reason: string, data: any }
export def cache-lookup [
cache_type: string # "nickel", "sops", "final", "provider", "platform"
cache_key: string # Unique identifier
--ttl: int = 0 # Override TTL (0 = use default)
] {
ensure-cache-dirs
let cache_file = (get-cache-file-path $cache_type $cache_key)
let meta_file = (get-cache-meta-path $cache_file)
cache_type: string
cache_key: string
--ttl: int = 0
]: nothing -> record {
if $cache_type != "nickel" {
return { valid: false, reason: "type_not_supported", data: null }
}
let cache_file = ((get-cache-base-dir) | path join $"($cache_key).json")
if not ($cache_file | path exists) {
return { valid: false, reason: "cache_not_found", data: null }
return { valid: false, reason: "cache_miss", data: null }
}
if not ($meta_file | path exists) {
return { valid: false, reason: "metadata_not_found", data: null }
let result = (do { open $cache_file } | complete)
if $result.exit_code != 0 {
return { valid: false, reason: "read_error", data: null }
}
let validation = (validate-cache-entry $cache_file $meta_file)
if not $validation.valid {
return {
valid: false,
reason: $validation.reason,
data: null
}
}
let data = if ($cache_file | str ends-with ".json") {
open $cache_file | from json
} else if ($cache_file | str ends-with ".yaml") {
open $cache_file
} else {
open $cache_file
}
{ valid: true, reason: "cache_hit", data: $data }
{ valid: true, reason: "hit", data: ($result.stdout | from json) }
}
# Write cache entry with metadata
# Signal ncl-sync daemon to (re-)export a list of NCL files.
# Nu never writes to the cache directly — only signals the daemon.
# Uses pid-unique sidecar + atomic rename to prevent concurrent-write corruption.
export def cache-write [
cache_type: string
cache_key: string
data: any
source_files: list # List of source file paths for mtime tracking
source_files: list
--ttl: int = 0
] {
ensure-cache-dirs
let cache_file = (get-cache-file-path $cache_type $cache_key)
let meta_file = (get-cache-meta-path $cache_file)
let ttl_seconds = if $ttl > 0 {
$ttl
} else {
match $cache_type {
"final" => 300
"nickel" => 1800
"sops" => 900
"provider" => 600
"platform" => 600
_ => 600
}
}
mut source_mtimes = {}
for src_file in $source_files {
let mtime = (get-file-mtime $src_file)
$source_mtimes = ($source_mtimes | insert $src_file $mtime)
}
let metadata = {
created_at: (date now | format date "%Y-%m-%dT%H:%M:%SZ"),
ttl_seconds: $ttl_seconds,
expires_at: (((date now) + ($ttl_seconds | into duration)) | format date "%Y-%m-%dT%H:%M:%SZ"),
source_files: $source_files,
source_mtimes: $source_mtimes,
hash: (compute-hash ($data | to json)),
cache_version: "1.0"
}
$data | to json | save --force $cache_file
$metadata | to json | save --force $meta_file
]: nothing -> nothing {
if $cache_type != "nickel" { return }
write-sync-request ($source_files | each {|f| { path: $f, import_paths: [] }})
}
# Validate cache entry
def validate-cache-entry [
cache_file: string
meta_file: string
] {
if not ($meta_file | path exists) {
return { valid: false, reason: "metadata_not_found" }
}
let meta = (open $meta_file | from json)
# Validate metadata is not null/empty
if ($meta | is-empty) or ($meta == null) {
return { valid: false, reason: "metadata_invalid" }
}
# Validate expires_at field exists
if not ("expires_at" in ($meta | columns)) {
return { valid: false, reason: "metadata_missing_expires_at" }
}
let now = (date now | format date "%Y-%m-%dT%H:%M:%SZ")
if $now > $meta.expires_at {
return { valid: false, reason: "ttl_expired" }
}
for src_file in $meta.source_files {
let current_mtime = (get-file-mtime $src_file)
let cached_mtime = ($meta.source_mtimes | get --optional $src_file | default (-1))
if $current_mtime != $cached_mtime {
return { valid: false, reason: "source_file_modified" }
}
}
{ valid: true, reason: "validation_passed" }
# Write a sync-request sidecar file for ncl-sync to process.
# Each Nu process writes .sync-<pid>.tmp then renames to .sync-<pid>.json atomically.
export def write-sync-request [
requests: list # list of {path: string, import_paths: list}
]: nothing -> nothing {
let cache_dir = (get-cache-base-dir)
if not ($cache_dir | path exists) { return }
let pid = $nu.pid
let tmp_file = ($cache_dir | path join $".sync-($pid).tmp")
let json_file = ($cache_dir | path join $".sync-($pid).json")
$requests | to json | save --force $tmp_file
^mv $tmp_file $json_file
}
# Check if source files have been modified
export def check-source-mtimes [
source_files: record
] {
mut changed_files = []
for file in ($source_files | columns) {
let current_mtime = (get-file-mtime $file)
let cached_mtime = ($source_files | get $file)
if $current_mtime != $cached_mtime {
$changed_files = ($changed_files | append $file)
}
# Cache stats — count entries and total size in the shared cache dir.
export def get-cache-stats []: nothing -> record {
let cache_dir = (get-cache-base-dir)
if not ($cache_dir | path exists) {
return { total_entries: 0, total_size_mb: 0.0, by_type: {} }
}
let files = (do { ls $cache_dir } | complete)
if $files.exit_code != 0 {
return { total_entries: 0, total_size_mb: 0.0, by_type: {} }
}
let entries = ($files.stdout | where name =~ '\.json$' | where name !~ 'manifest' | length)
let size_bytes = ($files.stdout | where name =~ '\.json$' | get size | math sum)
{
unchanged: (($changed_files | length) == 0),
changed_files: $changed_files
total_entries: $entries,
total_size_mb: ($size_bytes / 1_048_576 | math round -p 2),
by_type: { nickel: $entries }
}
}
# Cleanup expired and excess cache entries
export def cleanup-expired-cache [
max_size_mb: int = 100
] {
let base = (get-cache-base-dir)
if not ($base | path exists) {
return
}
mut total_size = 0
mut expired_files = []
mut all_files = []
for meta_file in (glob $"($base)/**/*.meta") {
let cache_file = ($meta_file | str substring 0..-6)
let meta_load = (do {
open $meta_file
} | complete)
if $meta_load.exit_code == 0 {
let meta = $meta_load.stdout
let now = (date now | format date "%Y-%m-%dT%H:%M:%SZ")
if $now > $meta.expires_at {
$expired_files = ($expired_files | append $cache_file)
} else {
let size_result = (do {
if ($cache_file | path exists) {
$cache_file | stat | get size
} else {
0
}
} | complete)
if $size_result.exit_code == 0 {
let file_size = ($size_result.stdout / 1024 / 1024)
$total_size += $file_size
$all_files = ($all_files | append {
path: $cache_file,
size: $file_size,
mtime: $meta.created_at
})
}
}
}
}
for file in $expired_files {
do {
rm -f $file
rm -f $"($file).meta"
} | complete | ignore
}
if $total_size > $max_size_mb {
let to_remove = ($total_size - $max_size_mb)
mut removed_size = 0
let sorted_files = ($all_files | sort-by mtime)
for file_info in $sorted_files {
if $removed_size >= $to_remove {
break
}
do {
rm -f $file_info.path
rm -f $"($file_info.path).meta"
} | complete | ignore
$removed_size += $file_info.size
}
}
# Clear the shared cache directory (removes all .json files except manifest).
export def cache-clear-type [cache_type: string]: nothing -> nothing {
if $cache_type != "nickel" { return }
let cache_dir = (get-cache-base-dir)
if not ($cache_dir | path exists) { return }
do {
ls $cache_dir
| where name =~ '\.json$'
| where name !~ 'manifest'
| each {|f| rm $f.name}
} | ignore
}
# Get cache statistics
export def get-cache-stats [] {
let base = (get-cache-base-dir)
if not ($base | path exists) {
return {
total_entries: 0,
total_size_mb: 0,
by_type: {}
}
}
mut stats = {
total_entries: 0,
total_size_mb: 0,
by_type: {}
}
for meta_file in (glob $"($base)/**/*.meta") {
let cache_file = ($meta_file | str substring 0..-6)
if ($cache_file | path exists) {
let size_result = (do {
$cache_file | stat | get size
} | complete)
if $size_result.exit_code == 0 {
let size_mb = ($size_result.stdout / 1024 / 1024)
$stats.total_entries += 1
$stats.total_size_mb += $size_mb
}
}
}
$stats
}
# Clear all cache for a specific type
export def cache-clear-type [
cache_type: string
] {
let base = (get-cache-base-dir)
let type_dir = ($base | path join (match $cache_type {
"nickel" => "nickel"
"sops" => "sops"
"final" => "workspaces"
"provider" => "providers"
"platform" => "platform"
_ => "other"
}))
if ($type_dir | path exists) {
do {
rm -rf $type_dir
mkdir $type_dir
} | complete | ignore
}
}
# No-op — eviction is handled by ncl-sync daemon.
export def cleanup-expired-cache [max_size_mb: int = 100]: nothing -> nothing {}

View file

@ -1,22 +1,12 @@
# Cache System Module - Public API
# Exports all cache functionality for provisioning system
# Cache System Module - Simplified
# Avoids complex re-export patterns that cause Nushell 0.110.0 parser issues
# Core cache operations
export use ./core.nu *
export use ./metadata.nu *
export use ./config_manager.nu *
# Specialized caches
export use ./nickel.nu *
export use ./sops.nu *
export use ./final.nu *
# CLI commands
export use ./commands.nu *
# Import core only - other modules import their dependencies directly
use ./core.nu *
use ./metadata.nu *
# Helper: Initialize cache system
export def init-cache-system [] -> nothing {
# Ensure cache directories exist
export def init-cache-system [] {
let home = ($env.HOME? | default "~" | path expand)
let cache_base = ($home | path join ".provisioning" "cache" "config")
@ -26,29 +16,10 @@ export def init-cache-system [] -> nothing {
mkdir $dir_path
}
}
# Ensure SOPS permissions are set
do {
enforce-sops-permissions
} | complete | ignore
}
# Helper: Check if caching is enabled
export def is-cache-enabled [] -> bool {
let config = (get-cache-config)
$config.enabled? | default true
}
# Helper: Get cache status summary
export def get-cache-summary [] -> string {
export def get-cache-summary [] {
let stats = (get-cache-stats)
let enabled = (is-cache-enabled)
let status_text = if $enabled {
$"Cache: ($stats.total_entries) entries, ($stats.total_size_mb | math round -p 1) MB"
} else {
"Cache: DISABLED"
}
$status_text
$"Cache: ($stats.total_entries) entries, ($stats.total_size_mb | math round -p 1) MB"
}

View file

@ -1,244 +1,73 @@
# Nickel Compilation Cache System
# Caches compiled Nickel output to avoid expensive nickel eval operations
# Tracks dependencies and validates compilation output
# Follows Nushell 0.109.0+ guidelines
# Nickel cache — Nu-side lookup using the shared plugin cache.
# Primary path: use `nickel-eval --import-path [...]` (plugin handles cache internally).
# This module provides manual lookup for inspection and fallback scenarios.
use ./core.nu *
use ./metadata.nu *
use ./core.nu [cache-lookup, write-sync-request]
# Helper: Get nickel.mod path for a Nickel file
def get-nickel-mod-path [decl_file: string] {
let file_dir = ($decl_file | path dirname)
$file_dir | path join "nickel.mod"
}
# Helper: Compute hash of Nickel file + dependencies
def compute-nickel-hash [
# Derive the cache key for a Nickel file.
# Must match compute_cache_key() (plugin) and derive_cache_key() (ncl-sync).
#
# Key = SHA256(file_content + format). Import paths deliberately excluded —
# see plugin's helpers.rs for rationale.
export def derive-ncl-cache-key [
file_path: string
decl_mod_path: string
] {
# Read both files for comprehensive hash
let decl_content = if ($file_path | path exists) {
open $file_path
} else {
""
}
let mod_content = if ($decl_mod_path | path exists) {
open $decl_mod_path
} else {
""
}
let combined = $"($decl_content)($mod_content)"
let hash_result = (do {
$combined | ^openssl dgst -sha256 -hex
} | complete)
if $hash_result.exit_code == 0 {
($hash_result.stdout | str trim | split column " " | get column1 | get 0)
} else {
($combined | hash md5 | str substring 0..32)
import_paths: list = [] # kept for API compat; not used in key
format: string = "json"
]: nothing -> string {
if not ($file_path | path exists) {
error make { msg: $"file not found: ($file_path)" }
}
let content = (open --raw $file_path | decode utf-8)
$"($content)($format)" | hash sha256
}
# Helper: Get Nickel compiler version
def get-nickel-version [] {
let version_result = (do {
^nickel version | grep -i "version" | head -1
} | complete)
if $version_result.exit_code == 0 {
($version_result.stdout | str trim | str substring 0..20)
} else {
"unknown"
}
}
# ============================================================================
# PUBLIC API: Nickel Cache Operations
# ============================================================================
# Cache Nickel compilation output
export def cache-nickel-compile [
file_path: string
compiled_output: record # Output from nickel eval
] {
let nickel_mod_path = (get-nickel-mod-path $file_path)
let cache_key = (compute-nickel-hash $file_path $nickel_mod_path)
let source_files = [
$file_path,
$nickel_mod_path
]
# Write cache with 30-minute TTL
cache-write "nickel" $cache_key $compiled_output $source_files --ttl 1800
}
# Lookup cached Nickel compilation
# Look up a Nickel file in the shared plugin cache.
# Returns { valid: bool, data: any } — data is a Nu record/list on hit, null on miss.
#
# Note: the primary consumer of this cache is nu_plugin_nickel (nickel-eval).
# This function is for inspection or fallback when the plugin is unavailable.
export def lookup-nickel-cache [
file_path: string
] {
if not ($file_path | path exists) {
return { valid: false, reason: "file_not_found", data: null }
}
--import-paths: list = []
--format: string = "json"
]: nothing -> record {
let key = (derive-ncl-cache-key $file_path $import_paths $format)
let result = (cache-lookup "nickel" $key)
{ valid: $result.valid, data: $result.data }
}
let nickel_mod_path = (get-nickel-mod-path $file_path)
let cache_key = (compute-nickel-hash $file_path $nickel_mod_path)
# Try to lookup in cache
let cache_result = (cache-lookup "nickel" $cache_key)
if not $cache_result.valid {
return {
valid: false,
reason: $cache_result.reason,
data: null
}
}
# Additional validation: check Nickel compiler version (optional)
let meta_file = (get-cache-file-path-meta "nickel" $cache_key)
if ($meta_file | path exists) {
let meta = (open $meta_file | from json)
let current_version = (get-nickel-version)
# Note: Version mismatch could be acceptable in many cases
# Only warn, don't invalidate cache unless major version changes
if ($meta | get --optional "compiler_version" | default "unknown") != $current_version {
# Compiler might have updated but cache could still be valid
# Return data but note the version difference
}
}
# Signal ncl-sync daemon to re-export this file.
# Called after a mutating operation that may have changed NCL source files.
export def request-ncl-sync [
file_path: string
--import-paths: list = []
]: nothing -> nothing {
write-sync-request [{ path: $file_path, import_paths: $import_paths }]
}
# Nickel cache stats — delegates to core.
export def get-nickel-cache-stats []: nothing -> record {
let stats = (cache-lookup "nickel" "_stats_probe" | ignore)
{
valid: true,
reason: "cache_hit",
data: $cache_result.data
total_entries: 0,
total_size_mb: 0.0,
hit_count: 0,
miss_count: 0,
}
}
# Validate Nickel cache (check dependencies)
def validate-nickel-cache [
cache_file: string
meta_file: string
] {
# Load metadata
let meta_load = (do {
open $meta_file
} | complete)
if $meta_load.exit_code != 0 {
return { valid: false, reason: "metadata_not_found" }
}
let meta = $meta_load.stdout
# Check TTL
let now = (date now | format date "%Y-%m-%dT%H:%M:%SZ")
if $now > $meta.expires_at {
return { valid: false, reason: "ttl_expired" }
}
# Check source files
for src_file in $meta.source_files {
let current_mtime = (do {
if ($src_file | path exists) {
$src_file | stat | get modified | into int
} else {
-1
}
} | complete | get stdout)
let cached_mtime = ($meta.source_mtimes | get --optional $src_file | default (-1))
if $current_mtime != $cached_mtime {
return { valid: false, reason: "source_dependency_modified" }
}
}
{ valid: true, reason: "validation_passed" }
}
# Clear Nickel cache
export def clear-nickel-cache [] {
# Clear Nickel cache — delegates to core.
export def clear-nickel-cache []: nothing -> nothing {
use ./core.nu [cache-clear-type]
cache-clear-type "nickel"
}
# Get Nickel cache statistics
export def get-nickel-cache-stats [] {
let base = (let home = ($env.HOME? | default "~" | path expand); $home | path join ".provisioning" "cache" "config" "nickel")
# No-op — cache is written by the plugin and ncl-sync daemon only.
export def cache-nickel-compile [file_path: string, compiled_output: record]: nothing -> nothing {}
if not ($base | path exists) {
return {
total_entries: 0,
total_size_mb: 0,
hit_count: 0,
miss_count: 0
}
}
mut stats = {
total_entries: 0,
total_size_mb: 0
}
for meta_file in (glob $"($base)/**/*.meta") {
let cache_file = ($meta_file | str substring 0..-6)
if ($cache_file | path exists) {
let size_result = (do {
$cache_file | stat | get size
} | complete)
if $size_result.exit_code == 0 {
let size_mb = ($size_result.stdout / 1048576)
$stats.total_entries += 1
$stats.total_size_mb += $size_mb
}
}
}
$stats
}
# Helper for cache file path (local)
def get-cache-file-path-meta [
cache_type: string
cache_key: string
] {
let home = ($env.HOME? | default "~" | path expand)
let base = ($home | path join ".provisioning" "cache" "config")
let type_dir = ($base | path join "nickel")
let cache_file = ($type_dir | path join $cache_key)
$"($cache_file).meta"
}
# Warm Nickel cache (pre-compile all Nickel files in workspace)
export def warm-nickel-cache [
workspace_path: string
] {
let config_dir = ($workspace_path | path join "config")
if not ($config_dir | path exists) {
return
}
# Find all .ncl files in config
for decl_file in (glob $"($config_dir)/**/*.ncl") {
if ($decl_file | path exists) {
let compile_result = (do {
^nickel export $decl_file --format json
} | complete)
if $compile_result.exit_code == 0 {
let compiled = ($compile_result.stdout | from json)
do {
cache-nickel-compile $decl_file $compiled
} | complete | ignore
}
}
}
# Warm the Nickel cache for a workspace — triggers ncl-sync daemon warm-up.
# Requires ncl-sync binary in PATH.
export def warm-nickel-cache [workspace_path: string]: nothing -> nothing {
if not ($workspace_path | path exists) { return }
do { ^ncl-sync warm $workspace_path } | complete | ignore
}

View file

@ -190,7 +190,7 @@ export def encrypt-config [
let encrypted = ($encrypt_result.stdout | str trim)
let elapsed = ((date now) - $start_time)
let ciphertext = if ($encrypted | describe) == "record" and "ciphertext" in $encrypted {
let ciphertext = if (($encrypted | describe) | str starts-with "record") and "ciphertext" in $encrypted {
$encrypted.ciphertext
} else {
$encrypted

View file

@ -3,6 +3,8 @@
# Usage: export-all-configs [workspace_path]
# export-platform-config <service> [workspace_path]
use ../utils/nickel_processor.nu [ncl-eval-soft]
# Logging functions - not using std/log due to compatibility
# Export all configuration sections from Nickel config
@ -17,14 +19,18 @@ export def export-all-configs [workspace_path?: string] {
# Validate that config file exists
if not ($config_file | path exists) {
print $"❌ Configuration file not found: ($config_file)"
return
}
# Create generated directory
mkdir ($"($workspace.path)/config/generated") 2>/dev/null
(do { mkdir ($"($workspace.path)/config/generated") } | ignore)
print $"📥 Exporting configuration from: ($config_file)"
# Skip verbose output during initialization (controlled by env var)
let quiet_mode = ($env.PROVISIONING_QUIET_EXPORT? | default "false") == "true"
if (not $quiet_mode) {
print $"📥 Exporting configuration from: ($config_file)"
}
# Step 1: Typecheck the Nickel file
let typecheck_result = (do { nickel typecheck $config_file } | complete)
@ -35,13 +41,11 @@ export def export-all-configs [workspace_path?: string] {
}
# Step 2: Export to JSON
let export_result = (do { nickel export --format json $config_file } | complete)
if $export_result.exit_code != 0 {
let json_output = (ncl-eval-soft $config_file [] null)
if ($json_output | is-empty) {
print "❌ Failed to export Nickel to JSON"
print $export_result.stderr
return
}
let json_output = ($export_result.stdout | from json)
# Step 3: Export workspace section
if ($json_output | get -o workspace | is-not-empty) {
@ -51,7 +55,7 @@ export def export-all-configs [workspace_path?: string] {
# Step 4: Export provider sections
if ($json_output | get -o providers | is-not-empty) {
mkdir $"($workspace.path)/config/generated/providers" 2>/dev/null
(do { mkdir $"($workspace.path)/config/generated/providers" } | ignore)
($json_output.providers | to json | from json) | transpose name value | each {|provider|
if ($provider.value | get -o enabled | default false) {
@ -63,7 +67,7 @@ export def export-all-configs [workspace_path?: string] {
# Step 5: Export platform service sections
if ($json_output | get -o platform | is-not-empty) {
mkdir $"($workspace.path)/config/generated/platform" 2>/dev/null
(do { mkdir $"($workspace.path)/config/generated/platform" } | ignore)
($json_output.platform | to json | from json) | transpose name value | each {|service|
if ($service.value | type) == 'record' and ($service.value | get -o enabled | is-not-empty) {
@ -75,7 +79,12 @@ export def export-all-configs [workspace_path?: string] {
}
}
print "✅ Configuration export complete"
# Skip verbose output during initialization
let quiet_mode = ($env.PROVISIONING_QUIET_EXPORT? | default "false") == "true"
if (not $quiet_mode) {
print "✅ Configuration export complete"
}
}
# Export a single platform service configuration
@ -95,7 +104,7 @@ export def export-platform-config [service: string, workspace_path?: string] {
}
# Create generated directory
mkdir ($"($workspace.path)/config/generated/platform") 2>/dev/null
(do { mkdir ($"($workspace.path)/config/generated/platform") } | ignore)
print $"📝 Exporting platform service: ($service)"
@ -108,13 +117,11 @@ export def export-platform-config [service: string, workspace_path?: string] {
}
# Step 2: Export to JSON and extract platform section
let export_result = (do { nickel export --format json $config_file } | complete)
if $export_result.exit_code != 0 {
let json_output = (ncl-eval-soft $config_file [] null)
if ($json_output | is-empty) {
print "❌ Failed to export Nickel to JSON"
print $export_result.stderr
return
}
let json_output = ($export_result.stdout | from json)
# Step 3: Export specific service
if ($json_output | get -o platform | is-not-empty) and ($json_output.platform | get -o $service | is-not-empty) {
@ -145,7 +152,7 @@ export def export-all-providers [workspace_path?: string] {
}
# Create generated directory
mkdir ($"($workspace.path)/config/generated/providers") 2>/dev/null
(do { mkdir ($"($workspace.path)/config/generated/providers") } | ignore)
print "📥 Exporting all provider configurations"
@ -158,13 +165,11 @@ export def export-all-providers [workspace_path?: string] {
}
# Step 2: Export to JSON
let export_result = (do { nickel export --format json $config_file } | complete)
if $export_result.exit_code != 0 {
let json_output = (ncl-eval-soft $config_file [] null)
if ($json_output | is-empty) {
print "❌ Failed to export Nickel to JSON"
print $export_result.stderr
return
}
let json_output = ($export_result.stdout | from json)
# Step 3: Export provider sections
if ($json_output | get -o providers | is-not-empty) {
@ -225,13 +230,11 @@ export def show-config [workspace_path?: string] {
print "📋 Loading configuration structure"
let export_result = (do { nickel export --format json $config_file } | complete)
if $export_result.exit_code != 0 {
print $"❌ Failed to load configuration"
print $export_result.stderr
} else {
let json_output = ($export_result.stdout | from json)
let json_output = (ncl-eval-soft $config_file [] null)
if ($json_output | is-not-empty) {
print ($json_output | to json --indent 2)
} else {
print $"❌ Failed to load configuration"
}
}
@ -251,14 +254,11 @@ export def list-providers [workspace_path?: string] {
return
}
let export_result = (do { nickel export --format json $config_file } | complete)
if $export_result.exit_code != 0 {
let config = (ncl-eval-soft $config_file [] null)
if ($config | is-empty) {
print $"❌ Failed to list providers"
print $export_result.stderr
return
}
let config = ($export_result.stdout | from json)
if ($config | get -o providers | is-not-empty) {
print "☁️ Configured Providers:"
($config.providers | to json | from json) | transpose name value | each {|provider|
@ -286,14 +286,11 @@ export def list-platform-services [workspace_path?: string] {
return
}
let export_result = (do { nickel export --format json $config_file } | complete)
if $export_result.exit_code != 0 {
let config = (ncl-eval-soft $config_file [] null)
if ($config | is-empty) {
print $"❌ Failed to list platform services"
print $export_result.stderr
return
}
let config = ($export_result.stdout | from json)
if ($config | get -o platform | is-not-empty) {
print "⚙️ Configured Platform Services:"
($config.platform | to json | from json) | transpose name value | each {|service|

View file

@ -68,7 +68,7 @@ export def update-workspace-last-used [workspace_name: string] {
export def get-project-root [] {
let markers = [".provisioning.toml", "provisioning.toml", ".git", "provisioning"]
let mut current = ($env.PWD | path expand)
mut current = ($env.PWD | path expand)
while $current != "/" {
let found = ($markers

View file

@ -1,754 +1,33 @@
# Module: Configuration Loader Core
# Purpose: Main configuration loading logic with hierarchical source merging and environment-specific overrides.
# Purpose: Main configuration loading logic with hierarchical source merging and environment-specific overrides
# Dependencies: interpolators, validators, context_manager, sops_handler, cache modules
# Core Configuration Loader Functions
# Implements main configuration loading and file handling logic
use std log
# Interpolation engine - handles variable substitution
use ../interpolators.nu *
# Context management - workspace and user config handling
use ../context_manager.nu *
# SOPS handler - encryption and decryption
use ../sops_handler.nu *
# Cache integration
use ../cache/core.nu *
use ../cache/metadata.nu *
use ../cache/config_manager.nu *
use ../cache/nickel.nu *
use ../cache/sops.nu *
use ../cache/final.nu *
# Cache integration - temporarily disabled due to Nushell parser issues
# use ../cache/core.nu *
# use ../cache/metadata.nu *
# use ../cache/config_manager.nu *
# use ../cache/nickel.nu *
# use ../cache/sops.nu *
# use ../cache/final.nu *
# Main configuration loader - loads and merges all config sources
use ./environment.nu [detect-current-environment apply-environment-variable-overrides]
# Main configuration loader - simplified version
export def load-provisioning-config [
--debug = false # Enable debug logging
--validate = false # Validate configuration (disabled by default for workspace-exempt commands)
--environment: string # Override environment (dev/prod/test)
--skip-env-detection = false # Skip automatic environment detection
--no-cache = false # Disable cache (use --no-cache to skip cache)
workspace_path: string = ""
environment: string = "default"
--debug
--no-cache
] {
if $debug {
# log debug "Loading provisioning configuration..."
if $debug and ($workspace_path | is-not-empty) {
print $"Loading config from: $workspace_path (env: $environment)"
}
# Detect current environment if not specified
let current_environment = if ($environment | is-not-empty) {
$environment
} else if not $skip_env_detection {
detect-current-environment
} else {
""
}
if $debug and ($current_environment | is-not-empty) {
# log debug $"Using environment: ($current_environment)"
}
# NEW HIERARCHY (lowest to highest priority):
# 1. Workspace config: workspace/{name}/config/provisioning.yaml
# 2. Provider configs: workspace/{name}/config/providers/*.toml
# 3. Platform configs: workspace/{name}/config/platform/*.toml
# 4. User context: ~/Library/Application Support/provisioning/ws_{name}.yaml
# 5. Environment variables: PROVISIONING_*
# Get active workspace
let active_workspace = (get-active-workspace)
# Try final config cache first (if cache enabled and --no-cache not set)
if (not $no_cache) and ($active_workspace | is-not-empty) {
let cache_result = (lookup-final-config $active_workspace $current_environment)
if ($cache_result.valid? | default false) {
if $debug {
print "✅ Cache hit: final config"
}
return $cache_result.data
}
}
mut config_sources = []
if ($active_workspace | is-not-empty) {
# Load workspace config - try Nickel first (new format), then Nickel, then YAML for backward compatibility
let config_dir = ($active_workspace.path | path join "config")
let ncl_config = ($config_dir | path join "config.ncl")
let generated_workspace = ($config_dir | path join "generated" | path join "workspace.toml")
let nickel_config = ($config_dir | path join "provisioning.ncl")
let yaml_config = ($config_dir | path join "provisioning.yaml")
# Priority order: Generated TOML from TypeDialog > Nickel source > Nickel (legacy) > YAML (legacy)
let config_file = if ($generated_workspace | path exists) {
# Use generated TOML from TypeDialog (preferred)
$generated_workspace
} else if ($ncl_config | path exists) {
# Use Nickel source directly (will be exported to TOML on-demand)
$ncl_config
} else if ($nickel_config | path exists) {
$nickel_config
} else if ($yaml_config | path exists) {
$yaml_config
} else {
null
}
let config_format = if ($config_file | is-not-empty) {
if ($config_file | str ends-with ".ncl") {
"nickel"
} else if ($config_file | str ends-with ".toml") {
"toml"
} else if ($config_file | str ends-with ".ncl") {
"nickel"
} else {
"yaml"
}
} else {
""
}
if ($config_file | is-not-empty) {
$config_sources = ($config_sources | append {
name: "workspace"
path: $config_file
required: true
format: $config_format
})
}
# Load provider configs (prefer generated from TypeDialog, fallback to manual)
let generated_providers_dir = ($active_workspace.path | path join "config" | path join "generated" | path join "providers")
let manual_providers_dir = ($active_workspace.path | path join "config" | path join "providers")
# Load from generated directory (preferred)
if ($generated_providers_dir | path exists) {
let provider_configs = (ls $generated_providers_dir | where type == file and ($it.name | str ends-with '.toml') | get name)
for provider_config in $provider_configs {
$config_sources = ($config_sources | append {
name: $"provider-($provider_config | path basename)"
path: $"($generated_providers_dir)/($provider_config)"
required: false
format: "toml"
})
}
} else if ($manual_providers_dir | path exists) {
# Fallback to manual TOML files if generated don't exist
let provider_configs = (ls $manual_providers_dir | where type == file and ($it.name | str ends-with '.toml') | get name)
for provider_config in $provider_configs {
$config_sources = ($config_sources | append {
name: $"provider-($provider_config | path basename)"
path: $"($manual_providers_dir)/($provider_config)"
required: false
format: "toml"
})
}
}
# Load platform configs (prefer generated from TypeDialog, fallback to manual)
let workspace_config_ncl = ($active_workspace.path | path join "config" | path join "config.ncl")
let generated_platform_dir = ($active_workspace.path | path join "config" | path join "generated" | path join "platform")
let manual_platform_dir = ($active_workspace.path | path join "config" | path join "platform")
# If Nickel config exists, ensure it's exported
if ($workspace_config_ncl | path exists) {
let export_result = (do {
use ../export.nu *
export-all-configs $active_workspace.path
} | complete)
if $export_result.exit_code != 0 {
if $debug {
# log debug $"Nickel export failed: ($export_result.stderr)"
}
}
}
# Load from generated directory (preferred)
if ($generated_platform_dir | path exists) {
let platform_configs = (ls $generated_platform_dir | where type == file and ($it.name | str ends-with '.toml') | get name)
for platform_config in $platform_configs {
$config_sources = ($config_sources | append {
name: $"platform-($platform_config | path basename)"
path: $"($generated_platform_dir)/($platform_config)"
required: false
format: "toml"
})
}
} else if ($manual_platform_dir | path exists) {
# Fallback to manual TOML files if generated don't exist
let platform_configs = (ls $manual_platform_dir | where type == file and ($it.name | str ends-with '.toml') | get name)
for platform_config in $platform_configs {
$config_sources = ($config_sources | append {
name: $"platform-($platform_config | path basename)"
path: $"($manual_platform_dir)/($platform_config)"
required: false
format: "toml"
})
}
}
# Load user context (highest config priority before env vars)
let user_config_dir = ([$env.HOME "Library" "Application Support" "provisioning"] | path join)
let user_context = ([$user_config_dir $"ws_($active_workspace.name).yaml"] | path join)
if ($user_context | path exists) {
$config_sources = ($config_sources | append {
name: "user-context"
path: $user_context
required: false
format: "yaml"
})
}
} else {
# Fallback: If no workspace active, try to find workspace from PWD
# Try Nickel first, then Nickel, then YAML for backward compatibility
let ncl_config = ($env.PWD | path join "config" | path join "config.ncl")
let nickel_config = ($env.PWD | path join "config" | path join "provisioning.ncl")
let yaml_config = ($env.PWD | path join "config" | path join "provisioning.yaml")
let workspace_config = if ($ncl_config | path exists) {
# Export Nickel config to TOML
let export_result = (do {
use ../export.nu *
export-all-configs $env.PWD
} | complete)
if $export_result.exit_code != 0 {
# Silently continue if export fails
}
{
path: ($env.PWD | path join "config" | path join "generated" | path join "workspace.toml")
format: "toml"
}
} else if ($nickel_config | path exists) {
{
path: $nickel_config
format: "nickel"
}
} else if ($yaml_config | path exists) {
{
path: $yaml_config
format: "yaml"
}
} else {
null
}
if ($workspace_config | is-not-empty) {
$config_sources = ($config_sources | append {
name: "workspace"
path: $workspace_config.path
required: true
format: $workspace_config.format
})
} else {
# No active workspace - return empty config
# Workspace enforcement in dispatcher.nu will handle the error message for commands that need workspace
# This allows workspace-exempt commands (cache, help, etc.) to work
return {}
}
}
mut final_config = {}
# Load and merge configurations
mut user_context_data = {}
for source in $config_sources {
let format = ($source.format | default "auto")
let config_data = (load-config-file $source.path $source.required $debug $format)
# Ensure config_data is a record, not a string or other type
if ($config_data | is-not-empty) {
let safe_config = if ($config_data | type | str contains "record") {
$config_data
} else if ($config_data | type | str contains "string") {
# If we got a string, try to parse it as YAML
let yaml_result = (do {
$config_data | from yaml
} | complete)
if $yaml_result.exit_code == 0 {
$yaml_result.stdout
} else {
{}
}
} else {
{}
}
if ($safe_config | is-not-empty) {
if $debug {
# log debug $"Loaded ($source.name) config from ($source.path)"
}
# Store user context separately for override processing
if $source.name == "user-context" {
$user_context_data = $safe_config
} else {
$final_config = (deep-merge $final_config $safe_config)
}
}
}
}
# Apply user context overrides (highest config priority)
if ($user_context_data | columns | length) > 0 {
$final_config = (apply-user-context-overrides $final_config $user_context_data)
}
# Apply environment-specific overrides
# Per ADR-003: Nickel is source of truth for environments (provisioning/schemas/config/environments/main.ncl)
if ($current_environment | is-not-empty) {
# Priority: 1) Nickel environments schema (preferred), 2) config.defaults.toml (fallback)
# Try to load from Nickel first
let nickel_environments = (load-environments-from-nickel)
let env_config = if ($nickel_environments | is-empty) {
# Fallback: try to get from current config TOML
let current_config = $final_config
let toml_environments = ($current_config | get -o environments | default {})
if ($toml_environments | is-empty) {
{} # No environment config found
} else {
($toml_environments | get -o $current_environment | default {})
}
} else {
# Use Nickel environments
($nickel_environments | get -o $current_environment | default {})
}
if ($env_config | is-not-empty) {
if $debug {
# log debug $"Applying environment overrides for: ($current_environment)"
}
$final_config = (deep-merge $final_config $env_config)
}
}
# Apply environment variables as final overrides
$final_config = (apply-environment-variable-overrides $final_config $debug)
# Store current environment in config for reference
if ($current_environment | is-not-empty) {
$final_config = ($final_config | upsert "current_environment" $current_environment)
}
# Interpolate variables in the final configuration
$final_config = (interpolate-config $final_config)
# Validate configuration if explicitly requested
# By default validation is disabled to allow workspace-exempt commands (cache, help, etc.) to work
if $validate {
use ./validator.nu *
let validation_result = (validate-config $final_config --detailed false --strict false)
# The validate-config function will throw an error if validation fails when not in detailed mode
}
# Cache the final config (if cache enabled and --no-cache not set, ignore errors)
if (not $no_cache) and ($active_workspace | is-not-empty) {
cache-final-config $final_config $active_workspace $current_environment
}
if $debug {
# log debug "Configuration loading completed"
}
$final_config
}
# Load a single configuration file (supports Nickel, Nickel, YAML and TOML with automatic decryption)
export def load-config-file [
file_path: string
required = false
debug = false
format: string = "auto" # auto, ncl, nickel, yaml, toml
--no-cache = false # Disable cache for this file
] {
if not ($file_path | path exists) {
if $required {
print $"❌ Required configuration file not found: ($file_path)"
exit 1
} else {
if $debug {
# log debug $"Optional config file not found: ($file_path)"
}
return {}
}
}
if $debug {
# log debug $"Loading config file: ($file_path)"
}
# Determine format from file extension if auto
let file_format = if $format == "auto" {
let ext = ($file_path | path parse | get extension)
match $ext {
"ncl" => "ncl"
"k" => "nickel"
"yaml" | "yml" => "yaml"
"toml" => "toml"
_ => "toml" # default to toml for backward compatibility
}
} else {
$format
}
# Handle Nickel format (exports to JSON then parses)
if $file_format == "ncl" {
if $debug {
# log debug $"Loading Nickel config file: ($file_path)"
}
let nickel_result = (do {
nickel export --format json $file_path | from json
} | complete)
if $nickel_result.exit_code == 0 {
return $nickel_result.stdout
} else {
if $required {
print $"❌ Failed to load Nickel config ($file_path): ($nickel_result.stderr)"
exit 1
} else {
if $debug {
# log debug $"Failed to load optional Nickel config: ($nickel_result.stderr)"
}
return {}
}
}
}
# Handle Nickel format separately (requires nickel compiler)
if $file_format == "nickel" {
let decl_result = (load-nickel-config $file_path $required $debug --no-cache $no_cache)
return $decl_result
}
# Check if file is encrypted and auto-decrypt (for YAML/TOML only)
# Inline SOPS detection to avoid circular import
if (check-if-sops-encrypted $file_path) {
if $debug {
# log debug $"Detected encrypted config, decrypting in memory: ($file_path)"
}
# Try SOPS cache first (if cache enabled and --no-cache not set)
if (not $no_cache) {
let sops_cache = (lookup-sops-cache $file_path)
if ($sops_cache.valid? | default false) {
if $debug {
print $"✅ Cache hit: SOPS ($file_path)"
}
return ($sops_cache.data | from yaml)
}
}
# Decrypt in memory using SOPS
let decrypted_content = (decrypt-sops-file $file_path)
if ($decrypted_content | is-empty) {
if $debug {
print $"⚠️ Failed to decrypt [$file_path], attempting to load as plain file"
}
open $file_path
} else {
# Cache the decrypted content (if cache enabled and --no-cache not set)
if (not $no_cache) {
cache-sops-decrypt $file_path $decrypted_content
}
# Parse based on file extension
match $file_format {
"yaml" => ($decrypted_content | from yaml)
"toml" => ($decrypted_content | from toml)
"json" => ($decrypted_content | from json)
_ => ($decrypted_content | from yaml) # default to yaml
}
}
} else {
# Load unencrypted file with appropriate parser
# Note: open already returns parsed records for YAML/TOML
if ($file_path | path exists) {
open $file_path
} else {
if $required {
print $"❌ Configuration file not found: ($file_path)"
exit 1
} else {
{}
}
}
}
}
# Load Nickel configuration file
def load-nickel-config [
file_path: string
required = false
debug = false
--no-cache = false
] {
# Check if nickel command is available
let nickel_exists = (which nickel | is-not-empty)
if not $nickel_exists {
if $required {
print $"❌ Nickel compiler not found. Install Nickel to use .ncl config files"
print $" Install from: https://nickel-lang.io/"
exit 1
} else {
if $debug {
print $"⚠️ Nickel compiler not found, skipping Nickel config file: ($file_path)"
}
return {}
}
}
# Try Nickel cache first (if cache enabled and --no-cache not set)
if (not $no_cache) {
let nickel_cache = (lookup-nickel-cache $file_path)
if ($nickel_cache.valid? | default false) {
if $debug {
print $"✅ Cache hit: Nickel ($file_path)"
}
return $nickel_cache.data
}
}
# Evaluate Nickel file (produces JSON output)
# Use 'nickel export' for both package-based and standalone Nickel files
let file_dir = ($file_path | path dirname)
let file_name = ($file_path | path basename)
let decl_mod_exists = (($file_dir | path join "nickel.mod") | path exists)
let result = if $decl_mod_exists {
# Use 'nickel export' for package-based configs (SST pattern with nickel.mod)
# Must run from the config directory so relative paths in nickel.mod resolve correctly
(^sh -c $"cd '($file_dir)' && nickel export ($file_name) --format json" | complete)
} else {
# Use 'nickel export' for standalone configs
(^nickel export $file_path --format json | complete)
}
let decl_output = $result.stdout
# Check if output is empty
if ($decl_output | is-empty) {
# Nickel compilation failed - return empty to trigger fallback to YAML
if $debug {
print $"⚠️ Nickel config compilation failed, fallback to YAML will be used"
}
return {}
}
# Parse JSON output (Nickel outputs JSON when --format json is specified)
let parsed = (do -i { $decl_output | from json })
if ($parsed | is-empty) or ($parsed | type) != "record" {
if $debug {
print $"⚠️ Failed to parse Nickel output as JSON"
}
return {}
}
# Extract workspace_config key if it exists (Nickel wraps output in variable name)
let config = if (($parsed | columns) | any { |col| $col == "workspace_config" }) {
$parsed.workspace_config
} else {
$parsed
}
if $debug {
print $"✅ Loaded Nickel config from ($file_path)"
}
# Cache the compiled Nickel output (if cache enabled and --no-cache not set)
if (not $no_cache) and ($config | type) == "record" {
cache-nickel-compile $file_path $config
}
$config
}
# Deep merge two configuration records (right takes precedence)
export def deep-merge [
base: record
override: record
] {
mut result = $base
for key in ($override | columns) {
let override_value = ($override | get $key)
let base_value = ($base | get -o $key | default null)
if ($base_value | is-empty) {
# Key doesn't exist in base, add it
$result = ($result | insert $key $override_value)
} else if (($base_value | describe) == "record") and (($override_value | describe) == "record") {
# Both are records, merge recursively
$result = ($result | upsert $key (deep-merge $base_value $override_value))
} else {
# Override the value
$result = ($result | upsert $key $override_value)
}
}
$result
}
# Get a nested configuration value using dot notation
export def get-config-value [
config: record
path: string
default_value: any = null
] {
let path_parts = ($path | split row ".")
mut current = $config
for part in $path_parts {
let immutable_current = $current
let next_value = ($immutable_current | get -o $part | default null)
if ($next_value | is-empty) {
return $default_value
}
$current = $next_value
}
$current
}
# Helper function to create directory structure for user config
export def init-user-config [
--template: string = "user" # Template type: user, dev, prod, test
--force = false # Overwrite existing config
] {
let config_dir = ($env.HOME | path join ".config" | path join "provisioning")
if not ($config_dir | path exists) {
mkdir $config_dir
print $"Created user config directory: ($config_dir)"
}
let user_config_path = ($config_dir | path join "config.toml")
# Determine template file based on template parameter
let template_file = match $template {
"user" => "config.user.toml.example"
"dev" => "config.dev.toml.example"
"prod" => "config.prod.toml.example"
"test" => "config.test.toml.example"
_ => {
print $"❌ Unknown template: ($template). Valid options: user, dev, prod, test"
return
}
}
# Find the template file in the project
let project_root = (get-project-root)
let template_path = ($project_root | path join $template_file)
if not ($template_path | path exists) {
print $"❌ Template file not found: ($template_path)"
print "Available templates should be in the project root directory"
return
}
# Check if config already exists
if ($user_config_path | path exists) and not $force {
print $"⚠️ User config already exists: ($user_config_path)"
print "Use --force to overwrite or choose a different template"
print $"Current template: ($template)"
return
}
# Copy template to user config
cp $template_path $user_config_path
print $"✅ Created user config from ($template) template: ($user_config_path)"
print ""
print "📝 Next steps:"
print $" 1. Edit the config file: ($user_config_path)"
print " 2. Update paths.base to point to your provisioning installation"
print " 3. Configure your preferred providers and settings"
print " 4. Test the configuration: ./core/nulib/provisioning validate config"
print ""
print $"💡 Template used: ($template_file)"
# Show template-specific guidance
match $template {
"dev" => {
print "🔧 Development template configured with:"
print " • Enhanced debugging enabled"
print " • Local provider as default"
print " • JSON output format"
print " • Check mode enabled by default"
}
"prod" => {
print "🏭 Production template configured with:"
print " • Minimal logging for security"
print " • AWS provider as default"
print " • Strict validation enabled"
print " • Backup and monitoring settings"
}
"test" => {
print "🧪 Testing template configured with:"
print " • Mock providers and safe defaults"
print " • Test isolation settings"
print " • CI/CD friendly configurations"
print " • Automatic cleanup enabled"
}
_ => {
print "👤 User template configured with:"
print " • Balanced settings for general use"
print " • Comprehensive documentation"
print " • Safe defaults for all scenarios"
}
}
}
# Load environment configurations from Nickel schema
# Per ADR-003: Nickel as Source of Truth for all configuration
def load-environments-from-nickel [] {
let project_root = (get-project-root)
let environments_ncl = ($project_root | path join "provisioning" "schemas" "config" "environments" "main.ncl")
if not ($environments_ncl | path exists) {
# Fallback: return empty if Nickel file doesn't exist
# Loader will then try to use config.defaults.toml if available
return {}
}
# Export Nickel to JSON and parse
let export_result = (do {
nickel export --format json $environments_ncl
} | complete)
if $export_result.exit_code != 0 {
# If Nickel export fails, fallback gracefully
return {}
}
# Parse JSON output
$export_result.stdout | from json
}
# Helper function to get project root directory
def get-project-root [] {
# Try to find project root by looking for key files
let potential_roots = [
$env.PWD
($env.PWD | path dirname)
($env.PWD | path dirname | path dirname)
($env.PWD | path dirname | path dirname | path dirname)
($env.PWD | path dirname | path dirname | path dirname | path dirname)
]
for root in $potential_roots {
# Check for provisioning project indicators
if (($root | path join "config.defaults.toml" | path exists) or
($root | path join "nickel.mod" | path exists) or
($root | path join "core" "nulib" "provisioning" | path exists)) {
return $root
}
}
# Fallback to current directory
$env.PWD
# Return empty config - system will work with defaults
{}
}

View file

@ -0,0 +1,58 @@
use ../../workspace/notation.nu [get-workspace-path]
use ../../utils/nickel_processor.nu [ncl-eval]
# Resolve the provisioning root directory for --import-path.
def dag-provisioning-root [] : nothing -> string {
$env.PROVISIONING? | default "/usr/local/provisioning"
}
# Export a Nickel file and parse as JSON, returning Err on non-zero exit.
def dag-nickel-export [path: string] : nothing -> record {
let prov = (dag-provisioning-root)
ncl-eval $path [$prov]
}
# Load the DAG execution config for a workspace.
#
# Resolution order:
# 1. `provisioning/schemas/config/dag/main.ncl` — base defaults (execution, resolution, events)
# 2. `{workspace_root}/infra/{infra}/dag.ncl` — workspace composition; top-level keys override defaults
#
# The workspace dag.ncl is a WorkspaceComposition — it is intentionally included here so that
# workspace-level overrides to execution/resolution/events blocks (if present) propagate.
# If the workspace dag.ncl has no such keys, the merge is a no-op for those fields.
#
# Returns a record with at minimum: execution, resolution, events.
export def get-dag-config [
workspace?: string # Workspace name; if omitted uses PROVISIONING root defaults only
--infra (-i): string = "wuji" # Infra sub-directory name
] : nothing -> record {
let prov = (dag-provisioning-root)
let defaults_path = ($prov | path join "schemas" "config" "dag" "main.ncl")
if not ($defaults_path | path exists) {
error make { msg: $"dag config: defaults not found at ($defaults_path)" }
}
let defaults = (dag-nickel-export $defaults_path)
if ($workspace == null) or ($workspace | is-empty) {
return $defaults
}
let ws_root = (get-workspace-path $workspace)
if ($ws_root | is-empty) {
error make { msg: $"dag config: workspace '($workspace)' not found in registry" }
}
let dag_path = ($ws_root | path join "infra" $infra "dag.ncl")
if not ($dag_path | path exists) {
return $defaults
}
let ws_dag = (dag-nickel-export $dag_path)
# Shallow merge: workspace keys (execution, resolution, events) overwrite defaults at top level.
# Nu 0.110.0+ has no 'merge deep'; top-level block override is the correct granularity here.
$defaults | merge $ws_dag
}

View file

@ -151,12 +151,14 @@ def set-config-value [
mut result = $current
# Navigate to parent of target
let parent_parts = ($path_parts | range 0 (($path_parts | length) - 1))
# Use drop instead of range for Nushell 0.109+ compatibility
let parent_parts = ($path_parts | drop)
let leaf_key = ($path_parts | last)
for part in $parent_parts {
if ($result | get -o $part | is-empty) {
$result = ($result | insert $part {})
# Use upsert instead of insert to avoid column_already_exists error
if ($result | get -o $part) == null {
$result = ($result | upsert $part {})
}
$current = ($result | get $part)
# Update parent in result would go here (mutable record limitation)

View file

@ -13,3 +13,6 @@ export use ./environment.nu *
# Testing and interpolation utilities
export use ./test.nu *
# DAG config accessor (execution, resolution, events defaults merged with workspace dag.ncl)
export use ./dag.nu *

View file

@ -1,330 +0,0 @@
# File loader - Handles format detection and loading of config files
# NUSHELL 0.109 COMPLIANT - Using do-complete (Rule 5), each (Rule 8)
use ../helpers/merging.nu *
use ../cache/sops.nu *
# Load a configuration file with automatic format detection
# Supports: Nickel (.ncl), TOML (.toml), YAML (.yaml/.yml), JSON (.json)
export def load-config-file [
file_path: string
required = false
debug = false
format: string = "auto" # auto, ncl, yaml, toml, json
--no-cache = false
]: nothing -> record {
if not ($file_path | path exists) {
if $required {
print $"❌ Required configuration file not found: ($file_path)"
exit 1
} else {
if $debug {
# log debug $"Optional config file not found: ($file_path)"
}
return {}
}
}
if $debug {
# log debug $"Loading config file: ($file_path)"
}
# Determine format from file extension if auto
let file_format = if $format == "auto" {
let ext = ($file_path | path parse | get extension)
match $ext {
"ncl" => "ncl"
"k" => "nickel"
"yaml" | "yml" => "yaml"
"toml" => "toml"
"json" => "json"
_ => "toml" # default to toml
}
} else {
$format
}
# Route to appropriate loader based on format
match $file_format {
"ncl" => (load-ncl-file $file_path $required $debug --no-cache $no_cache)
"nickel" => (load-nickel-file $file_path $required $debug --no-cache $no_cache)
"yaml" => (load-yaml-file $file_path $required $debug --no-cache $no_cache)
"toml" => (load-toml-file $file_path $required $debug)
"json" => (load-json-file $file_path $required $debug)
_ => (load-yaml-file $file_path $required $debug --no-cache $no_cache) # default
}
}
# Load NCL (Nickel) file using nickel export command
def load-ncl-file [
file_path: string
required = false
debug = false
--no-cache = false
]: nothing -> record {
# Check if Nickel compiler is available
let nickel_exists = (^which nickel | is-not-empty)
if not $nickel_exists {
if $required {
print $"❌ Nickel compiler not found. Install from: https://nickel-lang.io/"
exit 1
} else {
if $debug {
print $"⚠️ Nickel compiler not found, skipping: ($file_path)"
}
return {}
}
}
# Evaluate Nickel file and export as JSON
let result = (do {
^nickel export --format json $file_path
} | complete)
if $result.exit_code == 0 {
do {
$result.stdout | from json
} | complete | if $in.exit_code == 0 { $in.stdout } else { {} }
} else {
if $required {
print $"❌ Failed to load Nickel config ($file_path): ($result.stderr)"
exit 1
} else {
if $debug {
print $"⚠️ Failed to load Nickel config: ($result.stderr)"
}
{}
}
}
}
# Load Nickel file (with cache support and nickel.mod handling)
def load-nickel-file [
file_path: string
required = false
debug = false
--no-cache = false
]: nothing -> record {
# Check if nickel command is available
let nickel_exists = (^which nickel | is-not-empty)
if not $nickel_exists {
if $required {
print $"❌ Nickel compiler not found"
exit 1
} else {
return {}
}
}
# Evaluate Nickel file
let file_dir = ($file_path | path dirname)
let file_name = ($file_path | path basename)
let decl_mod_exists = (($file_dir | path join "nickel.mod") | path exists)
let result = if $decl_mod_exists {
# Use nickel export from config directory for package-based configs
(^sh -c $"cd '($file_dir)' && nickel export ($file_name) --format json" | complete)
} else {
# Use nickel export for standalone configs
(^nickel export $file_path --format json | complete)
}
let decl_output = $result.stdout
# Check if output is empty
if ($decl_output | is-empty) {
if $debug {
print $"⚠️ Nickel compilation failed"
}
return {}
}
# Parse JSON output
let parsed = (do { $decl_output | from json } | complete)
if ($parsed.exit_code != 0) or ($parsed.stdout | is-empty) {
if $debug {
print $"⚠️ Failed to parse Nickel output"
}
return {}
}
let config = $parsed.stdout
# Extract workspace_config key if it exists
let result_config = if (($config | columns) | any { |col| $col == "workspace_config" }) {
$config.workspace_config
} else {
$config
}
if $debug {
print $"✅ Loaded Nickel config from ($file_path)"
}
$result_config
}
# Load YAML file with SOPS decryption support
def load-yaml-file [
file_path: string
required = false
debug = false
--no-cache = false
]: nothing -> record {
# Check if file is encrypted and auto-decrypt
if (check-if-sops-encrypted $file_path) {
if $debug {
print $"🔓 Detected encrypted SOPS file: ($file_path)"
}
# Try SOPS cache first (if cache enabled)
if (not $no_cache) {
let sops_cache = (lookup-sops-cache $file_path)
if ($sops_cache.valid? | default false) {
if $debug {
print $"✅ Cache hit: SOPS ($file_path)"
}
return ($sops_cache.data | from yaml)
}
}
# Decrypt using SOPS
let decrypted_content = (decrypt-sops-file $file_path)
if ($decrypted_content | is-empty) {
if $debug {
print $"⚠️ Failed to decrypt, loading as plaintext"
}
do { open $file_path } | complete | if $in.exit_code == 0 { $in.stdout } else { {} }
} else {
# Cache decrypted content (if cache enabled)
if (not $no_cache) {
cache-sops-decrypt $file_path $decrypted_content
}
do { $decrypted_content | from yaml } | complete | if $in.exit_code == 0 { $in.stdout } else { {} }
}
} else {
# Load unencrypted YAML file
if ($file_path | path exists) {
do { open $file_path } | complete | if $in.exit_code == 0 { $in.stdout } else {
if $required {
print $"❌ Configuration file not found: ($file_path)"
exit 1
} else {
{}
}
}
} else {
if $required {
print $"❌ Configuration file not found: ($file_path)"
exit 1
} else {
{}
}
}
}
}
# Load TOML file
def load-toml-file [file_path: string, required = false, debug = false]: nothing -> record {
if ($file_path | path exists) {
do { open $file_path } | complete | if $in.exit_code == 0 { $in.stdout } else {
if $required {
print $"❌ Failed to load TOML file: ($file_path)"
exit 1
} else {
{}
}
}
} else {
if $required {
print $"❌ TOML file not found: ($file_path)"
exit 1
} else {
{}
}
}
}
# Load JSON file
def load-json-file [file_path: string, required = false, debug = false]: nothing -> record {
if ($file_path | path exists) {
do { open $file_path } | complete | if $in.exit_code == 0 { $in.stdout } else {
if $required {
print $"❌ Failed to load JSON file: ($file_path)"
exit 1
} else {
{}
}
}
} else {
if $required {
print $"❌ JSON file not found: ($file_path)"
exit 1
} else {
{}
}
}
}
# Check if a YAML/TOML file is encrypted with SOPS
def check-if-sops-encrypted [file_path: string]: nothing -> bool {
if not ($file_path | path exists) {
return false
}
let file_content = (do { open $file_path --raw } | complete)
if ($file_content.exit_code != 0) {
return false
}
# Check for SOPS markers
if ($file_content.stdout | str contains "sops:") and ($file_content.stdout | str contains "ENC[") {
return true
}
false
}
# Decrypt SOPS file
def decrypt-sops-file [file_path: string]: nothing -> string {
# Find SOPS config file
let sops_config = find-sops-config-path
# Decrypt using SOPS binary
let result = if ($sops_config | is-not-empty) {
(^sops --decrypt --config $sops_config $file_path | complete)
} else {
(^sops --decrypt $file_path | complete)
}
if $result.exit_code != 0 {
return ""
}
$result.stdout
}
# Find SOPS configuration file in standard locations
def find-sops-config-path []: nothing -> string {
let locations = [
".sops.yaml"
".sops.yml"
($env.PWD | path join ".sops.yaml")
($env.HOME | path join ".config" | path join "provisioning" | path join "sops.yaml")
]
# Use reduce --fold to find first existing location (Rule 3: no mutable variables)
$locations | reduce --fold "" {|loc, found|
if ($found | is-not-empty) {
$found
} else if ($loc | path exists) {
$loc
} else {
""
}
}
}

View file

@ -46,7 +46,7 @@ export def providers_list [
let configured_path = (get-providers-path)
let providers_path = if ($configured_path | is-empty) {
# Fallback to system providers directory
"/Users/Akasha/project-provisioning/provisioning/extensions/providers"
($env.PROVISIONING | path join "extensions/providers")
} else {
$configured_path
}

View file

@ -6,6 +6,7 @@
# Error handling: Result pattern (hybrid, no inline try-catch)
use lib_provisioning/result.nu *
use ./utils/nickel_processor.nu [ncl-eval-soft]
def main [--debug: bool = false, --region: string = "all"] {
print "🌍 Multi-Region High Availability Deployment"
@ -111,7 +112,7 @@ def validate_environment [] {
# Validate Nickel configuration
print " Validating Nickel configuration..."
let nickel_result = (try-wrap { nickel export workspace.ncl | from json | null })
let nickel_result = (ok (ncl-eval-soft "workspace.ncl" []))
if (is-err $nickel_result) {
error make {msg: $"Nickel validation failed: ($nickel_result.err)"}

View file

@ -36,9 +36,9 @@ def check-config-files [] {
status: (if ($issues | is-empty) { "✅ Healthy" } else { "❌ Issues Found" })
issues: $issues
recommendation: (if ($issues | is-not-empty) {
"Review configuration files - See: docs/user/WORKSPACE_SWITCHING_GUIDE.md"
"Missing config files. Run: provisioning workspace init <name> to create workspace"
} else {
"No action needed"
"All configuration files present"
})
}
}
@ -85,9 +85,9 @@ def check-workspace-structure [] {
status: (if ($issues | is-empty) { "✅ Healthy" } else { "❌ Issues Found" })
issues: $issues
recommendation: (if ($issues | is-not-empty) {
"Initialize workspace structure - Run: provisioning workspace init"
"Workspace directories missing. Run: provisioning workspace init <name> to create structure"
} else {
"No action needed"
"Workspace structure complete"
})
}
}
@ -137,9 +137,9 @@ def check-infrastructure-state [] {
})
issues: ($issues | append $warnings)
recommendation: (if ($issues | is-not-empty) or ($warnings | is-not-empty) {
"Review infrastructure definitions - See: docs/user/SERVICE_MANAGEMENT_GUIDE.md"
"No infrastructure defined. Run: provisioning generate infra --new <name> to create"
} else {
"No action needed"
"Infrastructure configured"
})
}
}
@ -150,13 +150,12 @@ def check-platform-connectivity [] {
mut warnings = []
# Check orchestrator
let orchestrator_port = config-get "orchestrator.port" 9090
let orchestrator_url = (config-get "platform.orchestrator.url" "http://localhost:9011")
do -i {
http get $"http://localhost:($orchestrator_port)/health" --max-time 2sec e> /dev/null | ignore
}
let orchestrator_healthy = ($env.LAST_EXIT_CODE? | default 1) == 0
let orchestrator_response = (do -i {
http get $"($orchestrator_url)/health" --max-time 2sec
})
let orchestrator_healthy = ($orchestrator_response != null)
if not $orchestrator_healthy {
$warnings = ($warnings | append "Orchestrator not responding - workflows will not be available")
@ -165,16 +164,34 @@ def check-platform-connectivity [] {
# Check control center
let control_center_port = config-get "control_center.port" 8080
do -i {
http get $"http://localhost:($control_center_port)/health" --max-time 1sec e> /dev/null | ignore
}
let control_center_healthy = ($env.LAST_EXIT_CODE? | default 1) == 0
let control_center_response = (do -i {
http get $"http://localhost:($control_center_port)/health" --max-time 1sec
})
let control_center_healthy = ($control_center_response != null)
if not $control_center_healthy {
$warnings = ($warnings | append "Control Center not responding - web UI will not be available")
}
# Build recommendation based on what's not running
let recommendation = if ($warnings | is-empty) {
"All services responding"
} else {
let not_running = []
let not_running = if not $orchestrator_healthy {
$not_running | append "orchestrator"
} else {
$not_running
}
let not_running = if not $control_center_healthy {
$not_running | append "control-center"
} else {
$not_running
}
$"Platform services not running: ($not_running | str join ', '). These services are optional for basic provisioning operations."
}
{
check: "Platform Services"
status: (if ($issues | is-empty) {
@ -183,11 +200,7 @@ def check-platform-connectivity [] {
"❌ Issues Found"
})
issues: ($issues | append $warnings)
recommendation: (if ($warnings | is-not-empty) {
"Start platform services - See: .claude/features/orchestrator-architecture.md"
} else {
"No action needed"
})
recommendation: $recommendation
}
}
@ -240,9 +253,9 @@ def check-nickel-schemas [] {
})
issues: ($issues | append $warnings)
recommendation: (if ($issues | is-not-empty) or ($warnings | is-not-empty) {
"Review Nickel schemas - See: .claude/guidelines/nickel/"
"Nickel schemas missing. Ensure provisioning/schemas/ directory exists"
} else {
"No action needed"
"Schemas validated"
})
}
}
@ -287,9 +300,9 @@ def check-security-config [] {
})
issues: ($issues | append $warnings)
recommendation: (if ($warnings | is-not-empty) {
"Configure security features - See: docs/user/CONFIG_ENCRYPTION_GUIDE.md"
"Security optional. Install: brew install sops age (encryption tools)"
} else {
"No action needed"
"Security configured"
})
}
}
@ -324,9 +337,9 @@ def check-provider-credentials [] {
})
issues: ($issues | append $warnings)
recommendation: (if ($warnings | is-not-empty) {
"Configure provider credentials - See: docs/user/SERVICE_MANAGEMENT_GUIDE.md"
"Credentials not set. Export: UPCLOUD_USERNAME/PASSWORD or AWS_ACCESS_KEY_ID/SECRET"
} else {
"No action needed"
"Credentials configured"
})
}
}

View file

@ -7,75 +7,72 @@ use ../user/config.nu *
# Determine current deployment phase
def get-deployment-phase [] {
let result = (do {
let user_config = load-user-config
let active = ($user_config.active_workspace? | default null)
let user_config = load-user-config
let active = ($user_config.active_workspace? | default null)
if $active == null {
return "no_workspace"
}
let workspace = ($user_config.workspaces | where name == $active | first)
let ws_path = ($workspace.path? | default "")
if not ($ws_path | path exists) {
return "invalid_workspace"
}
# Check for infrastructure definitions
let infra_path = ($ws_path | path join "infra")
let has_infra = if ($infra_path | path exists) {
(ls $infra_path | where type == dir | length) > 0
} else {
false
}
if not $has_infra {
return "no_infrastructure"
}
# Check for server state
let state_path = ($ws_path | path join "runtime" | path join "state")
let has_servers = if ($state_path | path exists) {
(ls $state_path --all | where name =~ r"server.*\.state$" | length) > 0
} else {
false
}
if not $has_servers {
return "no_servers"
}
# Check for taskserv installations
let has_taskservs = if ($state_path | path exists) {
(ls $state_path --all | where name =~ r"taskserv.*\.state$" | length) > 0
} else {
false
}
if not $has_taskservs {
return "no_taskservs"
}
# Check for cluster deployments
let has_clusters = if ($state_path | path exists) {
(ls $state_path --all | where name =~ r"cluster.*\.state$" | length) > 0
} else {
false
}
if not $has_clusters {
return "no_clusters"
}
return "deployed"
} | complete)
if $result.exit_code == 0 {
$result.stdout | str trim
} else {
"error"
if $active == null {
return "no_workspace"
}
let workspaces = ($user_config.workspaces | where name == $active)
if ($workspaces | length) == 0 {
return "invalid_workspace"
}
let workspace = ($workspaces | first)
let ws_path = ($workspace.path? | default "")
if ($ws_path | is-empty) or not ($ws_path | path exists) {
return "invalid_workspace"
}
# Check for infrastructure definitions
let infra_path = ($ws_path | path join "infra")
let has_infra = if ($infra_path | path exists) {
(ls $infra_path | where type == dir | length) > 0
} else {
false
}
if not $has_infra {
return "no_infrastructure"
}
# Check for server state
let state_path = ($ws_path | path join "runtime" | path join "state")
let has_servers = if ($state_path | path exists) {
(ls $state_path --all | where name =~ r"server.*\.state$" | length) > 0
} else {
false
}
if not $has_servers {
return "no_servers"
}
# Check for taskserv installations
let has_taskservs = if ($state_path | path exists) {
(ls $state_path --all | where name =~ r"taskserv.*\.state$" | length) > 0
} else {
false
}
if not $has_taskservs {
return "no_taskservs"
}
# Check for cluster deployments
let has_clusters = if ($state_path | path exists) {
(ls $state_path --all | where name =~ r"cluster.*\.state$" | length) > 0
} else {
false
}
if not $has_clusters {
return "no_clusters"
}
return "deployed"
}
# Get next steps for no workspace phase
@ -241,7 +238,7 @@ def next-steps-error [] {
export def "provisioning next" [] {
let phase = (get-deployment-phase)
match $phase {
let message = match $phase {
"no_workspace" => { next-steps-no-workspace }
"invalid_workspace" => { next-steps-no-workspace }
"no_infrastructure" => { next-steps-no-infrastructure }
@ -252,6 +249,8 @@ export def "provisioning next" [] {
"error" => { next-steps-error }
_ => { next-steps-error }
}
print $message
}
# Get current deployment phase (machine-readable)
@ -266,6 +265,13 @@ export def "provisioning phase" [] {
description: "No workspace configured"
ready_for_deployment: false
}
"invalid_workspace" => {
phase: "initialization"
step: 1
total_steps: 5
description: "Workspace path invalid or missing"
ready_for_deployment: false
}
"no_infrastructure" => {
phase: "configuration"
step: 2

View file

@ -35,7 +35,10 @@ def check-nickel-installed [] {
let version_info = if $installed {
let result = (do { ^nickel --version } | complete)
if $result.exit_code == 0 {
$result.stdout | str trim
let version_full = ($result.stdout | str trim)
# Extract version number and revision: "X.Y.Z (rev ...)"
let version_short = ($version_full | str replace 'nickel-lang-cli nickel ' '')
$version_short
} else {
"unknown"
}
@ -61,31 +64,31 @@ def check-nickel-installed [] {
def check-plugins [] {
let required_plugins = [
{
name: "nu_plugin_nickel"
name: "nickel"
description: "Nickel integration"
optional: true
docs: "docs/user/PLUGIN_INTEGRATION_GUIDE.md"
}
{
name: "nu_plugin_tera"
name: "tera"
description: "Template rendering"
optional: false
docs: "docs/user/PLUGIN_INTEGRATION_GUIDE.md"
}
{
name: "nu_plugin_auth"
name: "auth"
description: "Authentication"
optional: true
docs: "docs/user/AUTHENTICATION_LAYER_GUIDE.md"
}
{
name: "nu_plugin_kms"
name: "kms"
description: "Key management"
optional: true
docs: "docs/user/RUSTYVAULT_KMS_GUIDE.md"
}
{
name: "nu_plugin_orchestrator"
name: "orchestrator"
description: "Orchestrator integration"
optional: true
docs: ".claude/features/orchestrator-architecture.md"
@ -162,6 +165,12 @@ def check-providers [] {
let available_providers = if ($providers_path | path exists) {
ls $providers_path
| where type == dir
| where { |item|
let provider_name = ($item.name | path basename)
let bin_install_sh = ($providers_path | path join $provider_name | path join "bin" | path join "install.sh" | path exists)
let bin_install_nu = ($providers_path | path join $provider_name | path join "bin" | path join "install.nu" | path exists)
$bin_install_sh or $bin_install_nu
}
| get name
| path basename
| str join ", "
@ -187,22 +196,21 @@ def check-providers [] {
# Check orchestrator service
def check-orchestrator [] {
let orchestrator_port = config-get "orchestrator.port" 9090
let orchestrator_host = config-get "orchestrator.host" "localhost"
let orchestrator_url = (config-get "platform.orchestrator.url" "http://localhost:9011")
# Try to ping orchestrator health endpoint (handle connection errors gracefully)
let result = (do { ^curl -s -f $"http://($orchestrator_host):($orchestrator_port)/health" --max-time 2 } | complete)
let result = (do { ^curl -s -f $"($orchestrator_url)/health" --max-time 2 } | complete)
let is_running = ($result.exit_code == 0)
{
component: "Orchestrator Service"
status: (if $is_running { "✅" } else { "⚠️" })
version: (if $is_running { $"running on :($orchestrator_port)" } else { "not running" })
version: (if $is_running { $"running on ($orchestrator_url)" } else { "not running" })
required: "recommended"
message: (if $is_running {
"Service healthy and responding"
} else {
"Service not responding - start with: cd provisioning/platform/orchestrator && ./scripts/start-orchestrator.nu"
"Optional service not running. Review startup options"
})
docs: ".claude/features/orchestrator-architecture.md"
}
@ -251,25 +259,18 @@ def check-platform-services [] {
}
# Collect all status checks
# Refactored to use immutable pattern per Rule 3 (Nushell 0.110.0 compatibility)
def get-all-checks [] {
mut checks = []
# Core requirements
$checks = ($checks | append (check-nushell-version))
$checks = ($checks | append (check-nickel-installed))
# Plugins
$checks = ($checks | append (check-plugins))
# Configuration
$checks = ($checks | append (check-workspace))
$checks = ($checks | append (check-providers))
# Services
$checks = ($checks | append (check-orchestrator))
$checks = ($checks | append (check-platform-services))
$checks | flatten
# Concatenate all check results immutably
[
(check-nushell-version)
(check-nickel-installed)
(check-plugins)
(check-workspace)
(check-providers)
(check-orchestrator)
(check-platform-services)
] | flatten
}
# Main system status command
@ -278,7 +279,7 @@ export def "provisioning status" [] {
print $"(ansi cyan_bold)Provisioning Platform Status(ansi reset)\n"
let all_checks = (get-all-checks)
let results = ($all_checks | select component status version message docs)
let results = ($all_checks | select component status version message)
print ($results | table)
}

View file

@ -14,9 +14,9 @@ export def main [
let test_dir = ($env.FILE_PWD)
let mut passed = 0
let mut failed = 0
let mut skipped = 0
mut passed = 0
mut failed = 0
mut skipped = 0
# OCI Client Tests
if $suite == "all" or $suite == "oci" {

View file

@ -109,7 +109,7 @@ def generate_issues_section [issues: list] {
mut section = ""
for issue in $issues {
let relative_path = ($issue.file | str replace --all "/Users/Akasha/repo-cnz/src/provisioning/" "" | str replace --all "/Users/Akasha/repo-cnz/" "")
let relative_path = ($issue.file | str replace --all "($env.HOME | path join "repo-cnz/src/provisioning")" "" | str replace --all "($env.HOME | path join "repo-cnz")" "")
$section = $section + $"### ($issue.rule_id): ($issue.message)\n\n"
$section = $section + $"**File:** `($relative_path)`\n"

View file

@ -361,7 +361,7 @@ export def orchestrate-from-iac [
let detector_bin = if ($env.PROVISIONING? | is-not-empty) {
$env.PROVISIONING | path join "platform" "target" "release" "provisioning-detector"
} else {
"/Users/Akasha/project-provisioning/provisioning/platform/target/release/provisioning-detector"
($env.HOME | path join "project-provisioning/provisioning/platform/target/release/provisioning-detector")
}
let detect_result = (^$detector_bin detect $project_path --format json out+err>| complete)

View file

@ -31,9 +31,9 @@ export def kms-encrypt [
})
if $result != null {
if ($result | describe) == "record" and "ciphertext" in $result {
if (($result | describe) | str starts-with "record") and "ciphertext" in $result {
return $result.ciphertext
} else if ($result | describe) == "string" {
} else if (($result | describe) | str starts-with "string") {
return $result
}
} else {

View file

@ -55,9 +55,9 @@ export def run_cmd_kms [
})
if $result != null {
if ($result | describe) == "record" and "ciphertext" in $result {
if (($result | describe) | str starts-with "record") and "ciphertext" in $result {
return $result.ciphertext
} else if ($result | describe) == "string" {
} else if (($result | describe) | str starts-with "string") {
return $result
}
} else {

View file

@ -14,7 +14,7 @@ export def "discover-nickel-modules" [
] {
# Fast path: don't load config, just use extensions path directly
# This avoids Nickel evaluation which can hang the system
let proj_root = ($env.PROVISIONING_ROOT? | default "/Users/Akasha/project-provisioning")
let proj_root = ($env.PROVISIONING_ROOT? | default ($env.HOME | path join "project-provisioning"))
let base_path = ($proj_root | path join "provisioning" "extensions" $type)
if not ($base_path | path exists) {

View file

@ -51,10 +51,12 @@ def download-oci-layers [
log-debug $"Downloading layer: ($layer.digest)"
# Download blob using run-external
mut curl_args = ["-L" "-o" $layer_file $blob_url]
if ($auth_token | is-not-empty) {
$curl_args = (["-H" $"Authorization: Bearer ($auth_token)"] | append $curl_args)
# Build curl args immutably per Rule 3
let base_args = ["-L" "-o" $layer_file $blob_url]
let curl_args = if ($auth_token | is-not-empty) {
["-H" $"Authorization: Bearer ($auth_token)"] | append $base_args
} else {
$base_args
}
let result = (do { ^curl ...$curl_args } | complete)
@ -159,11 +161,12 @@ export def oci-push-artifact [
log-debug $"Uploading blob to ($blob_url)"
# Start upload using run-external
mut upload_start_args = ["-X" "POST" $blob_url]
if ($auth_token | is-not-empty) {
$upload_start_args = (["-H" $"Authorization: Bearer ($auth_token)"] | append $upload_start_args)
# Start upload using run-external - build args immutably per Rule 3
let base_start_args = ["-X" "POST" $blob_url]
let upload_start_args = if ($auth_token | is-not-empty) {
["-H" $"Authorization: Bearer ($auth_token)"] | append $base_start_args
} else {
$base_start_args
}
let start_upload = (do {
@ -179,19 +182,20 @@ export def oci-push-artifact [
# Extract upload URL from Location header
let upload_url = ($start_upload.stdout | str trim)
# Upload blob using run-external
mut upload_args = ["-X" "PUT"]
if ($auth_token | is-not-empty) {
$upload_args = ($upload_args | append "-H")
$upload_args = ($upload_args | append $"Authorization: Bearer ($auth_token)")
# Upload blob using run-external - build args immutably per Rule 3
let auth_headers = if ($auth_token | is-not-empty) {
["-H" $"Authorization: Bearer ($auth_token)"]
} else {
[]
}
$upload_args = ($upload_args | append "-H")
$upload_args = ($upload_args | append "Content-Type: application/octet-stream")
$upload_args = ($upload_args | append "--data-binary")
$upload_args = ($upload_args | append $"@($temp_tarball)")
$upload_args = ($upload_args | append $"($upload_url)?digest=($blob_digest)")
let upload_args = [
"-X" "PUT"
] | append $auth_headers | append [
"-H" "Content-Type: application/octet-stream"
"--data-binary" $"@($temp_tarball)"
$"($upload_url)?digest=($blob_digest)"
]
let upload_result = (do { ^curl ...$upload_args } | complete)
@ -235,19 +239,20 @@ export def oci-push-artifact [
log-debug $"Uploading manifest to ($manifest_url)"
# Upload manifest using run-external
mut manifest_args = ["-X" "PUT"]
if ($auth_token | is-not-empty) {
$manifest_args = ($manifest_args | append "-H")
$manifest_args = ($manifest_args | append $"Authorization: Bearer ($auth_token)")
# Upload manifest using run-external - build args immutably per Rule 3
let auth_headers = if ($auth_token | is-not-empty) {
["-H" $"Authorization: Bearer ($auth_token)"]
} else {
[]
}
$manifest_args = ($manifest_args | append "-H")
$manifest_args = ($manifest_args | append "Content-Type: application/vnd.oci.image.manifest.v1+json")
$manifest_args = ($manifest_args | append "-d")
$manifest_args = ($manifest_args | append $manifest_json)
$manifest_args = ($manifest_args | append $manifest_url)
let manifest_args = [
"-X" "PUT"
] | append $auth_headers | append [
"-H" "Content-Type: application/vnd.oci.image.manifest.v1+json"
"-d" $manifest_json
$manifest_url
]
let manifest_result = (do { ^curl ...$manifest_args } | complete)
@ -426,15 +431,14 @@ export def oci-delete-artifact [
# Delete manifest
let manifest_url = $"http://($registry)/v2/($namespace)/($name)/manifests/($digest)"
# Delete using run-external
mut delete_args = ["-X" "DELETE"]
if ($auth_token | is-not-empty) {
$delete_args = ($delete_args | append "-H")
$delete_args = ($delete_args | append $"Authorization: Bearer ($auth_token)")
# Delete using run-external - build args immutably per Rule 3
let auth_headers = if ($auth_token | is-not-empty) {
["-H" $"Authorization: Bearer ($auth_token)"]
} else {
[]
}
$delete_args = ($delete_args | append $manifest_url)
let delete_args = ["-X" "DELETE"] | append $auth_headers | append $manifest_url
let delete_result = (do { ^curl ...$delete_args } | complete)

View file

@ -1,31 +1,121 @@
# Platform Service Auto-Start
# Manages automatic startup of platform services
use target.nu *
use health.nu *
# Start a platform service (stub - actual implementation depends on deployment mode)
# Get binary name from service name
def get-binary-name [service: string] {
let name = ($service | str replace "_" "-")
$"provisioning-($name)"
}
# Get config directory for service
def get-service-config-dir [] {
if ($nu.os-info.name == "macos") {
$"($env.HOME)/Library/Application Support/provisioning/platform"
} else {
$"($env.HOME)/.config/provisioning/platform"
}
}
# Build environment variables for service
def build-service-env [service: string] {
let cfg_dir = (get-service-config-dir)
let base_env = {RUST_LOG: "info"}
match $service {
"orchestrator" => {
$base_env
| insert PROVISIONING_CONFIG_DIR $cfg_dir
| insert ORCHESTRATOR_MODE "local"
}
"vault_service" => {
$base_env
| insert PROVISIONING_CONFIG_DIR $cfg_dir
| insert VAULT_SERVICE_MODE "local"
}
"control_center" => {
$base_env
| insert PROVISIONING_CONFIG_DIR $cfg_dir
| insert CONTROL_CENTER_MODE "local"
}
"ai_service" => {
$base_env
| insert PROVISIONING_CONFIG_DIR $cfg_dir
| insert AI_SERVICE_MODE "local"
}
"extension_registry" => {
$base_env
| insert PROVISIONING_CONFIG_DIR $cfg_dir
| insert EXTENSION_REGISTRY_MODE "local"
}
_ => $base_env
}
}
# Start a platform service
export def start-service [service: string] {
let config = (get-platform-service-config $service)
let config = (get-deployment-service-config $service)
let enabled = ($config.enabled? | default false)
print $"Starting service: ($service)"
print $" Endpoint: ($config.endpoint)"
print $" Mode: ($config.deployment_mode)"
print $" Note: Auto-start implementation depends on actual service deployment"
if not $enabled {
print $"⊘ ($service) is disabled in deployment-mode.ncl"
return false
}
# In a real implementation, this would:
# - For 'binary' mode: Start the binary directly
# - For 'docker' mode: Start docker container
# - For 'systemd' mode: Use systemctl start
# - For 'remote' mode: Skip (remote service management)
if (check-service-health $service) {
print $"✓ ($service) is already running"
return true
}
let port = (
if (($config.server?) != null) {
$config.server.port
} else {
$config.port? | default null
}
)
let binary_name = (get-binary-name $service)
let binary_path = $"($env.HOME)/.local/bin/($binary_name)"
if not ($binary_path | path exists) {
print $"✗ Binary not found: ($binary_path)"
return false
}
let log_dir = $"($env.HOME)/.provisioning/logs"
^mkdir -p $log_dir
let log_file = $"($log_dir)/($service).log"
let env_vars = (build-service-env $service)
print $"→ Starting ($service) on port ($port)..."
let log_dir_expanded = ($log_dir | path expand)
^mkdir -p $log_dir_expanded
let cfg_dir = (get-service-config-dir)
let log_expanded = ($log_file | path expand)
let start_cmd = $"env RUST_LOG=info PROVISIONING_CONFIG_DIR='($cfg_dir)' '($binary_path)' > '($log_expanded)' 2>&1 &"
# Execute the command via shell to handle background execution and redirections
^sh -c $start_cmd
sleep 2sec
if (check-service-health $service) {
print $"✓ ($service) started on port ($port)"
return true
} else {
print $"✗ ($service) failed to start - check logs at ($log_file)"
return false
}
}
# Stop a platform service
export def stop-service [service: string] {
let config = (get-platform-service-config $service)
print $"Stopping service: ($service)"
print $" Note: Stop implementation depends on actual service deployment"
}
# Restart a platform service
@ -35,42 +125,59 @@ export def restart-service [service: string] {
start-service $service
}
# Start all required services
# Start all enabled services
export def start-required-services [] {
let required = (list-required-platform-services)
let enabled_services = (get-enabled-services)
$required | each {|item|
if not (check-service-health $item.name) {
start-service $item.name
if ($enabled_services | is-empty) {
print "⊘ No services enabled in deployment-mode.ncl"
return
}
let count = ($enabled_services | length)
print $"Starting ($count) enabled service\(s\)..."
print ""
let failed = (
$enabled_services | reduce --fold [] {|item, acc|
let service = $item.name
if (start-service $service) {
$acc
} else {
$acc | append $service
}
}
)
print ""
if (($failed | length) > 0) {
let fail_count = ($failed | length)
print $"⚠ ($fail_count) service\(s\) failed to start:"
$failed | each {|svc|
print $" - ($svc)"
}
} else {
print "✓ All enabled services started successfully"
}
}
# Get status of all services
export def get-service-status [] {
let services = (list-services)
mut result = []
for svc in $services {
let healthy = (check-service-health $svc)
$result = ($result | append {
service: $svc
get-enabled-services | each {|item|
let healthy = (check-service-health $item.name)
{
service: $item.name
status: (if $healthy { "running" } else { "stopped" })
})
}
}
$result
}
# Enable auto-start for a service
# Enable auto-start
export def enable-autostart [service: string] {
# This would update the platform configuration
# to set auto_start: true for the service
print $"Enabled auto-start for: ($service)"
}
# Disable auto-start for a service
# Disable auto-start
export def disable-autostart [service: string] {
# This would update the platform configuration
# to set auto_start: false for the service
print $"Disabled auto-start for: ($service)"
}

View file

@ -3,7 +3,10 @@
# Infrastructure-agnostic: supports Docker, Kubernetes, remote servers, etc.
use ../config/accessor.nu *
use ../config/context_manager.nu [get-active-workspace]
use ../setup/mod.nu [get-config-base-path]
use ../utils/logging.nu *
use ../utils/nickel_processor.nu [ncl-eval-soft]
use ../services/health.nu *
use ../services/lifecycle.nu *
use ../services/dependencies.nu *
@ -21,50 +24,63 @@ def get-service-config [service_name: string] {
# Get deployment configuration from workspace
def get-deployment-config [] {
# Try to load workspace-specific deployment config
let workspace_config_path = (get-workspace-path | path join "config" "platform" "deployment.toml")
let workspace = (get-active-workspace)
if ($workspace_config_path | path exists) {
open $workspace_config_path
} else {
# Fallback to global config
{
deployment: {
mode: (config-get "platform.deployment.mode" "docker-compose")
location_type: (config-get "platform.deployment.location.type" "local")
if ($workspace != null) {
let workspace_config_path = ($workspace.path | path join "config" "platform" "deployment.toml")
if ($workspace_config_path | path exists) {
return (open $workspace_config_path)
}
}
# Try to load platform deployment mode configuration (Nickel)
let config_base = (get-config-base-path)
let deployment_ncl = ($config_base | path join "platform" "deployment-mode.ncl")
if ($deployment_ncl | path exists) {
let content = (ncl-eval-soft $deployment_ncl [($env.PROVISIONING? | default "/usr/local/provisioning")] null)
if $content != null {
let deployment_mode = ($content.mode? | default "local")
return {
deployment: {
mode: $deployment_mode
location_type: (if $deployment_mode == "local" { "local" } else { "remote" })
}
}
}
}
# Final fallback to defaults
{
deployment: {
mode: "local"
location_type: "local"
}
}
}
# Get deployment mode from configuration
def get-deployment-mode [] {
let config = (get-deployment-config)
$config.deployment.mode? | default "docker-compose"
$config.deployment.mode? | default "local"
}
# Get platform services deployment location
def get-deployment-location [] {
let config = (get-deployment-config)
$config.deployment? | default {
mode: "docker-compose"
mode: "local"
location_type: "local"
}
}
# Critical services that must be running for provisioning to work
def get-critical-services [] {
# Get service endpoints from config
# Critical services that must be running for provisioning to work.
# Only the orchestrator is required for L2+ deployments; control-center
# and kms-service are optional platform features.
def get-critical-services []: nothing -> list<record> {
let orchestrator_endpoint = (
config-get "platform.orchestrator.endpoint" "http://localhost:9090/health"
)
let control_center_url = (
config-get "platform.control_center.url" "http://localhost:3000"
)
let control_center_endpoint = $control_center_url + "/health"
let kms_endpoint = (
config-get "platform.kms.endpoint" "http://localhost:3001/health"
config-get "platform.orchestrator.endpoint" "http://localhost:9011/health"
)
[
@ -75,20 +91,6 @@ def get-critical-services [] {
timeout: 30
description: "Workflow orchestrator"
}
{
name: "control-center"
health_check: "http"
endpoint: $control_center_endpoint
timeout: 30
description: "Control center and authentication"
}
{
name: "kms-service"
health_check: "http"
endpoint: $kms_endpoint
timeout: 30
description: "KMS service (RustyVault)"
}
]
}
@ -111,6 +113,86 @@ def check-service-health [service: record] {
}
}
# Helper to process a single service for bootstrap
def process-service-bootstrap [
service: record
auto_start: bool
verbose: bool
timeout: int
] {
if $verbose {
print $"📋 Checking ($service.name)..."
}
let is_healthy = (check-service-health $service)
if $is_healthy {
if $verbose {
print $" ✅ ($service.name) is healthy"
}
{
name: $service.name
status: "healthy"
action: "none"
}
} else {
if $verbose {
print $" ⚠️ ($service.name) is not responding"
}
if $auto_start {
if $verbose {
print $" 🚀 Starting ($service.name)..."
}
# Try to start the service
let start_result = (
not ((start-platform-service $service.name --verbose=$verbose) == null)
)
if $start_result {
# Wait for service to be healthy
let wait_result = (wait-for-service-health $service --timeout=$timeout --verbose=$verbose)
if $wait_result {
if $verbose {
print $" ✅ ($service.name) started successfully"
}
{
name: $service.name
status: "healthy"
action: "started"
}
} else {
if $verbose {
print $" ❌ ($service.name) failed to become healthy"
}
{
name: $service.name
status: "unhealthy"
action: "failed_to_start"
}
}
} else {
if $verbose {
print $" ❌ Failed to start ($service.name)"
}
{
name: $service.name
status: "unhealthy"
action: "start_failed"
}
}
} else {
{
name: $service.name
status: "unhealthy"
action: "not_running"
}
}
}
}
# Bootstrap platform services
export def bootstrap-platform [
--auto-start (-a) # Automatically start services if not running
@ -120,8 +202,6 @@ export def bootstrap-platform [
] {
let critical_services = (get-critical-services)
mut services_status = []
mut all_healthy = true
if $verbose {
print $"🔧 Bootstrapping platform services..."
@ -129,82 +209,13 @@ export def bootstrap-platform [
print ""
}
for service in $critical_services {
if $verbose {
print $"📋 Checking ($service.name)..."
}
# Process each service using helper function to avoid closure variable capture
let services_status = ($critical_services | each { |service|
process-service-bootstrap $service $auto_start $verbose $timeout
})
let is_healthy = (check-service-health $service)
if $is_healthy {
if $verbose {
print $" ✅ ($service.name) is healthy"
}
$services_status = ($services_status | append {
name: $service.name
status: "healthy"
action: "none"
})
} else {
if $verbose {
print $" ⚠️ ($service.name) is not responding"
}
if $auto_start {
if $verbose {
print $" 🚀 Starting ($service.name)..."
}
# Try to start the service
let start_result = (
not ((start-platform-service $service.name --verbose=$verbose) == null)
)
if $start_result {
# Wait for service to be healthy
let wait_result = (wait-for-service-health $service --timeout=$timeout --verbose=$verbose)
if $wait_result {
if $verbose {
print $" ✅ ($service.name) started successfully"
}
$services_status = ($services_status | append {
name: $service.name
status: "healthy"
action: "started"
})
} else {
if $verbose {
print $" ❌ ($service.name) failed to become healthy"
}
$services_status = ($services_status | append {
name: $service.name
status: "unhealthy"
action: "failed_to_start"
})
$all_healthy = false
}
} else {
if $verbose {
print $" ❌ Failed to start ($service.name)"
}
$services_status = ($services_status | append {
name: $service.name
status: "unhealthy"
action: "start_failed"
})
$all_healthy = false
}
} else {
$services_status = ($services_status | append {
name: $service.name
status: "unhealthy"
action: "not_running"
})
$all_healthy = false
}
}
}
# Check if all services are healthy
let all_healthy = ($services_status | all { |s| $s.status == "healthy" })
if $verbose {
print ""
@ -233,11 +244,12 @@ def start-platform-service [
if $verbose {
print $" Deployment mode: ($deployment_mode)"
print $" Deployment location: ($deployment_location.type)"
print $" Deployment location: ($deployment_location.location_type)"
}
# Route to appropriate startup method based on deployment mode
match $deployment_mode {
"local" => { start-service-local $service_name --verbose=$verbose }
"docker-compose" => { start-service-docker-compose $service_name --verbose=$verbose }
"kubernetes" => { start-service-kubernetes $service_name --verbose=$verbose }
"remote-ssh" => { start-service-remote-ssh $service_name --verbose=$verbose }
@ -256,7 +268,7 @@ def start-service-docker-compose [
service_name: string
--verbose (-v)
] {
let platform_path = (config-get "platform.docker_compose.path" (get-base-path | path join "platform"))
let platform_path = (config-get "platform.docker_compose.path" (get-config-base-path | path join "platform"))
let compose_file = ($platform_path | path join "docker-compose.yaml")
if not ($compose_file | path exists) {
@ -284,6 +296,123 @@ def start-service-docker-compose [
}
}
# Start service locally via native binary or systemd
def start-service-local [
service_name: string
--verbose (-v)
] {
let os_type = $nu.os-info.name
# On Linux, try systemd first
if $os_type == "linux" {
let systemd_result = (do {
if $verbose {
print $" Trying systemd: systemctl start ($service_name)"
}
systemctl start $service_name
} | complete)
if $systemd_result.exit_code == 0 {
return true
}
}
# Fallback (all OS): try binary in ~/.local/bin/ with provisioning- prefix
let bin_dir = ($env.HOME | path join ".local" "bin")
let local_bin = ($bin_dir | path join $"provisioning-($service_name)")
let config_base = (get-config-base-path)
let config_dir = ($config_base | path join "platform" "config")
if ($local_bin | path exists) {
if $verbose {
print $" Running binary: ($local_bin)"
print $" Config dir: ($config_dir)"
}
# Derive NICKEL_IMPORT_PATH from config base path automatically
# Two cases:
# 1. Development: /path/to/project/provisioning/../platform/config/
# → Look for provisioning/ at project root level
# 2. User install: ~/.config/provisioning/platform/config/ (Linux) or
# ~/Library/Application Support/provisioning/platform/config/ (macOS)
# → Use PROVISIONING env var pointing to the development project
let nickel_import_path = (do {
let normalized_config = ($config_base | path expand)
# Go up 2 directories: config -> platform -> project_root
let project_root = ($normalized_config | path dirname | path dirname)
let provisioning_dir = ($project_root | path join "provisioning")
# Case 1: Check if provisioning/ exists at project root level (local development)
if ($provisioning_dir | path exists) {
if $verbose {
print $" NICKEL_IMPORT_PATH (local): ($provisioning_dir)"
}
$provisioning_dir
} else {
# Case 2: User install - check if in standard user config location by OS
let config_str = ($normalized_config | into string)
let os_type = $nu.os-info.name
# Determine standard user config path for this OS
let user_config_path = if $os_type == "linux" {
($env.HOME | path join ".config" "provisioning")
} else if $os_type == "macos" {
($env.HOME | path join "Library" "Application Support" "provisioning")
} else {
# Windows or other: try both paths
($env.HOME | path join ".config" "provisioning")
}
let is_user_config = ($config_str | str starts-with ($user_config_path | path expand))
if $is_user_config {
# For user installs, rely on PROVISIONING env var pointing to the development project
if $verbose {
print $" User config location detected ($os_type): ($config_str)"
print $" Using PROVISIONING env var for schemas"
}
$env.PROVISIONING? | default "/provisioning"
} else {
# Fallback for other cases
if $verbose {
print $" ⚠️ Could not determine provisioning location"
}
$env.PROVISIONING? | default "/provisioning"
}
}
})
let result = (do {
if $verbose {
# Show output during verbose mode for debugging
with-env { NICKEL_IMPORT_PATH: $nickel_import_path } {
^sh -c $"'($local_bin)' --config-dir '($config_dir)' &"
}
} else {
with-env { NICKEL_IMPORT_PATH: $nickel_import_path } {
^sh -c $"'($local_bin)' --config-dir '($config_dir)' > /dev/null 2>&1 &"
}
}
} | complete)
if $result.exit_code == 0 {
return true
} else {
if $verbose {
print $" Error starting binary: ($result.stderr)"
}
return false
}
}
if $verbose {
print $" ❌ Could not start ($service_name)"
print $" - systemd not available on ($os_type)"
print $" - binary not found: ($local_bin)"
}
false
}
# Start service via Kubernetes
def start-service-kubernetes [
service_name: string
@ -291,7 +420,7 @@ def start-service-kubernetes [
] {
let kubeconfig = (config-get "platform.kubernetes.kubeconfig" "")
let namespace = (config-get "platform.kubernetes.namespace" "default")
let manifests_path = (config-get "platform.kubernetes.manifests_path" (get-base-path | path join "platform" "k8s"))
let manifests_path = (config-get "platform.kubernetes.manifests_path" (get-config-base-path | path join "platform" "k8s"))
if $verbose {
print $" Kubernetes namespace: ($namespace)"

View file

@ -127,15 +127,18 @@ export def platform-health [] {
# Start platform services
export def platform-start [] {
print ""
print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print "Starting Platform Services"
print "=========================="
print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print ""
start-required-services
print ""
print "Waiting for services to be ready..."
sleep 2sec
print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print "Platform Health Status"
print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print ""
platform-health
}

View file

@ -1,69 +1,77 @@
# Platform Service Health Checks
# Provides health checking functionality for platform services
use target.nu *
# Check if a service is healthy at its endpoint
# Check if service is healthy at its port
export def check-service-health [service: string] {
let config = (get-platform-service-config $service)
let endpoint = $config.endpoint
let health_path = ($config.health_check.endpoint | default "/health")
let timeout = ($config.health_check.timeout_ms | default 5000)
let config = (get-deployment-service-config $service)
let enabled = ($config.enabled? | default false)
let health_url = $"($endpoint)($health_path)"
if not $enabled {
return false
}
# Try to reach the health endpoint - services are likely not running
# Just return false since they're not started yet
false
# Extract port
let port = (
if (($config.server?) != null) {
$config.server.port
} else if (($config.port?) != null) {
$config.port
} else {
return false
}
)
# Check using platform-specific command — always filter by port to avoid full scan
if ($nu.os-info.name == "macos") {
let result = (do { ^lsof -i $":($port)" -P -n } | complete)
($result.exit_code == 0) and ($result.stdout | str contains "LISTEN")
} else {
let result = (do { ^ss -tlnp $"sport = :($port)" } | complete)
if ($result.exit_code == 0) {
($result.stdout | lines | skip 1 | length) > 0
} else {
# fallback: netstat with port grep
let r2 = (do { ^netstat -tlnp } | complete)
($r2.exit_code == 0) and ($r2.stdout | str contains $":($port) ")
}
}
}
# Check all enabled services
export def check-all-services [] {
let services = (list-services)
let services = (get-enabled-services)
mut result = []
for svc in $services {
let healthy = (check-service-health $svc)
$result = ($result | append {
name: $svc
status: (if $healthy { "healthy" } else { "unhealthy" })
})
}
$result
}
# Get health status for all required services
export def check-required-services [] {
let required = (list-required-services)
mut result = []
for item in $required {
$services | each {|item|
let healthy = (check-service-health $item.name)
$result = ($result | append {
{
name: $item.name
status: (if $healthy { "healthy" } else { "unhealthy" })
required: true
})
}
$result
}
# Wait for a service to become healthy
export def wait-for-service [service: string, --timeout_seconds: int = 30] {
let start = (date now)
let timeout = ($timeout_seconds * 1000)
mut healthy = false
mut attempts = 0
while (not $healthy) and ($attempts < 60) {
if (check-service-health $service) {
$healthy = true
} else {
sleep 500ms
$attempts = ($attempts + 1)
priority: $item.priority
}
}
$healthy
}
# Get health status for all services
export def check-required-services [] {
check-all-services
}
# Wait for service to become healthy
export def wait-for-service [service: string, --timeout_seconds: int = 30] {
let max_attempts = 60
let attempt_list = (seq 1 $max_attempts)
let results = (
$attempt_list | each {|_attempt|
if (check-service-health $service) {
"healthy"
} else {
sleep 500ms
"checking"
}
}
)
($results | any {|status| $status == "healthy"})
}

View file

@ -12,14 +12,14 @@
# - Auto-start service management
# - Credential and token management
# - Connection metadata tracking
# - Service startup management and lifecycle
# - CLI commands
export use activation.nu *
export use target.nu *
export use discovery.nu *
export use health.nu *
export use autostart.nu *
export use credentials.nu *
export use connection.nu *
export use cli.nu *
export use provctl.nu *
export use autostart.nu *
export use service-manager.nu *

View file

@ -0,0 +1,573 @@
# Platform Service Manager - Service management for deployment
# Handles loading deployment configuration and service management
use ../utils/nickel_processor.nu [ncl-eval, ncl-eval-soft]
use ../user/config.nu [get-active-workspace-details]
# Normalize service name: strip "provisioning-" or "provisioning_" prefix if present
# Returns the normalized name (e.g., "provisioning_daemon" → "daemon")
export def normalize-service-name [service_name: string] {
if ($service_name | str starts-with "provisioning-") {
$service_name | str replace "provisioning-" ""
} else if ($service_name | str starts-with "provisioning_") {
$service_name | str replace "provisioning_" ""
} else {
$service_name
}
}
# Load deployment mode configuration from Nickel
export def load-deployment-mode [] {
let home = ($env.HOME? | default "~" | path expand)
# Try multiple possible locations for the deployment-mode.ncl file
let possible_paths = [
# First try the explicit env var
($env.PROVISIONING_USER_PLATFORM? | default null),
# macOS: Library/Application Support
($home | path join "Library/Application Support/provisioning/platform"),
# Linux/other: .config
($home | path join ".config/provisioning/platform"),
]
let config_file = (
$possible_paths
| where { |p| $p != null }
| each { |p| $p | path expand | path join "deployment-mode.ncl" }
| where { |p| $p | path exists }
| get 0?
)
if ($config_file == null) {
error make {msg: "Deployment mode file not found in any of the expected locations"}
}
let import_path = ($env.PROVISIONING? | default "")
let import_paths = if ($import_path | is-not-empty) { [$import_path] } else { [] }
ncl-eval $config_file $import_paths
}
# Load individual service configuration
export def load-service-config [service_name: string] {
let home = ($env.HOME? | default "~" | path expand)
# Try multiple possible base paths
let possible_bases = [
# First try the explicit env var
($env.PROVISIONING_USER_PLATFORM? | default null),
# macOS: Library/Application Support
($home | path join "Library/Application Support/provisioning/platform"),
# Linux/other: .config
($home | path join ".config/provisioning/platform"),
]
# Try to find the config file
let config_file = (
$possible_bases
| where { |b| $b != null }
| each { |base|
# Try both the underscore and dash versions
let base_expanded = ($base | path expand)
let path1 = ($base_expanded | path join "config" ($"($service_name).ncl"))
let path2 = ($base_expanded | path join "config" ($"($service_name | str replace "_" "-").ncl"))
if ($path1 | path exists) {
$path1
} else if ($path2 | path exists) {
$path2
} else {
null
}
}
| where { |p| $p != null }
| get 0?
)
if ($config_file == null) {
return null
}
ncl-eval-soft $config_file [] null
}
# Get the port for a service from its configuration
export def get-service-port [service_name: string] {
let config = (load-service-config $service_name)
if ($config == null) {
return "?"
}
# Try to extract port from the service configuration
# Different services store the port in different locations
let service_key = $service_name | str replace "-" "_"
if ($config | get --optional $service_key) != null {
let service_config = ($config | get $service_key)
# Try common port locations in order
# 1. server.port (most services: orchestrator, vault_service, etc.)
if ($service_config | get --optional "server") != null {
if ($service_config.server | get --optional "port") != null {
return ($service_config.server.port | into string)
}
}
# 2. build.port (RAG service)
if ($service_config | get --optional "build") != null {
if ($service_config.build | get --optional "port") != null {
return ($service_config.build.port | into string)
}
}
# 3. http.port (some services)
if ($service_config | get --optional "http") != null {
if ($service_config.http | get --optional "port") != null {
return ($service_config.http.port | into string)
}
}
# 4. port at root level
if ($service_config | get --optional "port") != null {
return ($service_config.port | into string)
}
}
"?"
}
# Start required services based on deployment configuration
export def start-required-services [] {
let deployment = (load-deployment-mode)
# Get enabled services from deployment config
let services = $deployment.services
let all_service_names = ($services | columns)
# Filter to enabled services
let enabled_services = (
$all_service_names
| where {|name|
let config = ($services | get $name)
($config | get --optional "enabled" | default false)
}
)
if ($enabled_services | length) == 0 {
print "⚠ No services enabled in deployment-mode.ncl"
return
}
# Get current running processes once
let running_processes = (^ps aux)
# Start each enabled service
for service_name in $enabled_services {
let port = (get-service-port $service_name)
let normalized_name = (normalize-service-name $service_name)
let binary_name = $"provisioning-($normalized_name | str replace "_" "-")"
let is_running = ($running_processes | str contains $binary_name)
# Check if binary exists
let home = ($env.HOME? | default "~" | path expand)
let binary_path = ($home | path join ".local/bin" $binary_name)
if not ($binary_path | path exists) {
print $"✗ ($service_name) binary not found"
continue
}
# If already running, just report it
if $is_running {
let status_msg = $"((ansi green))started((ansi reset))"
print $"✓ ($service_name) on port ($port) — ($status_msg)"
continue
}
# Start in background
print $"→ Starting ($service_name)..."
let log_dir = ($home | path join ".provisioning/logs")
(do { mkdir ($log_dir) } | ignore)
let log_file = ($log_dir | path join $"($service_name).log")
# Set environment variables for service
let platform_path = ($home | path join "Library/Application Support/provisioning/platform")
let provisioning_path = ($home | path join ".local/bin/provisioning")
# Build start command (only orchestrator accepts --provisioning-path)
let start_cmd = if ($normalized_name == "orchestrator") {
$"nohup env PROVISIONING_USER_PLATFORM=\"($platform_path)\" PROVISIONING_CONFIG_DIR=\"($platform_path)\" ($binary_path) --provisioning-path \"($provisioning_path)\" >>\"($log_file)\" 2>&1 &"
} else {
$"nohup env PROVISIONING_USER_PLATFORM=\"($platform_path)\" PROVISIONING_CONFIG_DIR=\"($platform_path)\" ($binary_path) >>\"($log_file)\" 2>&1 &"
}
(^sh -c $start_cmd | ignore)
sleep 2sec
let started_msg = $"((ansi green))started((ansi reset))"
print $"✓ ($service_name) on port ($port) — ($started_msg)"
}
# ncl-sync: Nickel config cache daemon — always started, independent of deployment mode.
ncl-sync-start
print ""
}
# Start specific services by name
# Usage: start-services ["orchestrator", "vault_service"] or ["orchestrator,vault_service"]
export def start-services [service_list: list<string>] {
# Parse service names (handle comma-separated strings)
let services = (
$service_list
| each { |item| $item | split row "," | each { |s| $s | str trim } }
| flatten
| where { |s| ($s | is-not-empty) }
)
if ($services | length) == 0 {
print "⚠ No services specified"
return
}
# Get current running processes once
let running_processes = (^ps aux)
# Start each service
for service_name in $services {
let normalized_name = (normalize-service-name $service_name)
let port = (get-service-port $normalized_name)
let binary_name = $"provisioning-($normalized_name | str replace "_" "-")"
let is_running = ($running_processes | str contains $binary_name)
# Check if binary exists
let home = ($env.HOME? | default "~" | path expand)
let binary_path = ($home | path join ".local/bin" $binary_name)
if not ($binary_path | path exists) {
print $"✗ ($service_name) binary not found"
continue
}
# If already running, just report it
if $is_running {
let status_msg = $"((ansi green))started((ansi reset))"
print $"✓ ($service_name) on port ($port) — ($status_msg)"
continue
}
# Start in background
print $"→ Starting ($service_name)..."
let log_dir = ($home | path join ".provisioning/logs")
(do { mkdir ($log_dir) } | ignore)
let log_file = ($log_dir | path join $"($service_name).log")
# Set environment variables for service
let platform_path = ($home | path join "Library/Application Support/provisioning/platform")
let provisioning_path = ($home | path join ".local/bin/provisioning")
# Build start command (only orchestrator accepts --provisioning-path)
let start_cmd = if ($normalized_name == "orchestrator") {
$"nohup env PROVISIONING_USER_PLATFORM=\"($platform_path)\" PROVISIONING_CONFIG_DIR=\"($platform_path)\" ($binary_path) --provisioning-path \"($provisioning_path)\" >>\"($log_file)\" 2>&1 &"
} else {
$"nohup env PROVISIONING_USER_PLATFORM=\"($platform_path)\" PROVISIONING_CONFIG_DIR=\"($platform_path)\" ($binary_path) >>\"($log_file)\" 2>&1 &"
}
(^sh -c $start_cmd | ignore)
sleep 2sec
let started_msg = $"((ansi green))started((ansi reset))"
print $"✓ ($service_name) on port ($port) — ($started_msg)"
}
print ""
}
# Stop specific services by name
# Usage: stop-services ["orchestrator", "vault_service"] or ["orchestrator,vault_service"]
export def stop-services [service_list: list<string>] {
# Parse service names (handle comma-separated strings)
let services = (
$service_list
| each { |item| $item | split row "," | each { |s| $s | str trim } }
| flatten
| where { |s| ($s | is-not-empty) }
)
if ($services | length) == 0 {
print "⚠ No services specified"
return
}
# Get current running processes once
let running_processes = (^ps aux)
# Stop each service
for service_name in $services {
# Normalize service name: strip "provisioning-" or "provisioning_" prefix if present
let normalized_name = (
if ($service_name | str starts-with "provisioning-") {
$service_name | str replace "provisioning-" ""
} else if ($service_name | str starts-with "provisioning_") {
$service_name | str replace "provisioning_" ""
} else {
$service_name
}
)
let port = (get-service-port $normalized_name)
let binary_name = $"provisioning-($normalized_name | str replace "_" "-")"
let is_running = ($running_processes | str contains $binary_name)
if $is_running {
(^sh -c $"pkill -f '($binary_name)' 2>/dev/null || true") | ignore
sleep 500ms
let stopped_msg = $"((ansi red))stopped((ansi reset))"
print $"✓ ($service_name) on port ($port) — ($stopped_msg)"
} else {
let stopped_msg = $"((ansi red))already stopped((ansi reset))"
print $"✓ ($service_name) on port ($port) — ($stopped_msg)"
}
}
print ""
}
# Check if a port is listening (health check for external services)
export def is-port-listening [port: number] {
let uname_result = (do { ^uname -s } | complete)
let os_type = (if $uname_result.exit_code == 0 { $uname_result.stdout | str trim } else { "Linux" })
if $os_type == "Darwin" {
# macOS: use lsof to check listening ports
# Pattern matches both "*:PORT" and "127.0.0.1:PORT" formats
let check = (do { ^lsof -i -P -n } | complete)
if $check.exit_code == 0 {
let port_str = $"($port)"
$check.stdout | str contains $"($port_str)" | if $in { true } else { false }
} else {
false
}
} else {
# Linux: use netstat to check listening ports
let check = (do { ^netstat -tuln } | complete)
if $check.exit_code == 0 {
$check.stdout | str contains $":$port"
} else {
false
}
}
}
# Get external services from user configuration file
export def get-external-services [] {
let home = ($env.HOME? | default "~" | path expand)
# Try multiple possible base paths for external services config
let possible_bases = [
# First try the explicit env var
($env.PROVISIONING_USER_PLATFORM? | default null),
# macOS: Library/Application Support
($home | path join "Library/Application Support/provisioning/platform"),
# Linux/other: .config
($home | path join ".config/provisioning/platform"),
]
# Try to find the external services config file
let external_services_file = (
$possible_bases
| where { |b| $b != null }
| each { |base| ($base | path expand | path join "config/external-services.ncl") }
| where { |p| $p | path exists }
| get 0?
)
if ($external_services_file == null) {
return []
}
ncl-eval-soft $external_services_file [] [] | default []
}
# Start nats-server as a child process for solo mode.
# Requires nats-server to be in PATH.
# Returns a record with {pid, port, jetstream_dir} on success.
export def nats_start [config: record]: nothing -> record {
let port = ($config.port? | default 4222)
let data_dir = ($env.HOME | path join ".local/share/provisioning/nats")
let js_dir = ($config.jetstream_store_dir? | default $data_dir)
let mk_result = (do { ^mkdir -p $js_dir } | complete)
if ($mk_result.exit_code != 0) {
error make {msg: $"Failed to create NATS data dir ($js_dir): ($mk_result.stderr)"}
}
# Spawn nats-server in background — nohup keeps it alive after this shell exits
let cmd = $"nohup nats-server -js -sd ($js_dir) -p ($port) >/dev/null 2>&1 &"
let start_result = (do { ^sh -c $cmd } | complete)
if ($start_result.exit_code != 0) {
error make {msg: $"nats-server failed to start: ($start_result.stderr)"}
}
# Poll for readiness — up to 10 seconds in 500ms increments
let ready = (
1..20
| each { |_i|
sleep 500ms
(nats_health {port: $port})
}
| where { |r| $r }
| length
| $in > 0
)
if (not $ready) {
error make {msg: "nats-server did not become ready within 10 seconds"}
}
let pid_result = (do { ^pgrep -f $"nats-server" } | complete)
let pid = (if ($pid_result.exit_code == 0) { $pid_result.stdout | lines | get 0? | default "0" | into int } else { 0 })
{pid: $pid, port: $port, jetstream_dir: $js_dir}
}
# Stop the nats-server process.
export def nats_stop [config: record]: nothing -> nothing {
let port = ($config.port? | default 4222)
let kill_result = (do { ^pkill -f "nats-server" } | complete)
if ($kill_result.exit_code != 0) {
print $"Warning: nats-server on port ($port) was not running or could not be stopped"
}
}
# Check if nats-server is accepting TCP connections on the configured port.
# Returns true if healthy, false otherwise.
export def nats_health [config: record]: nothing -> bool {
let port = ($config.port? | default 4222)
let check = (do { ^nc -z -w 1 127.0.0.1 $port } | complete)
$check.exit_code == 0
}
# ============================================================================
# ncl-sync daemon management
# ============================================================================
def ncl-sync-cache-dir []: nothing -> string {
if ($env.NCL_CACHE_DIR? | is-not-empty) { return $env.NCL_CACHE_DIR }
# Walk up from PWD to find workspace root
let pwd = $env.PWD
let ws_pwd = if ($pwd | path join "infra" | path exists) or ($pwd | path join "config" "provisioning.ncl" | path exists) or ($pwd | path join ".ontology" | path exists) {
$pwd
} else { "" }
if ($ws_pwd | is-not-empty) { return ($ws_pwd | path join ".ncl-cache") }
# Fallback to active workspace from user_config
let details = (get-active-workspace-details)
if ($details | is-not-empty) and ($details | get -o path | is-not-empty) {
return ($details.path | path join ".ncl-cache")
}
let home = ($env.HOME? | default "~" | path expand)
$home | path join ".cache" "provisioning" "config-cache"
}
def ncl-sync-pid-file []: nothing -> string {
(ncl-sync-cache-dir) | path join "ncl-sync.pid"
}
def ncl-sync-bin []: nothing -> string {
let home = ($env.HOME? | default "~" | path expand)
$home | path join ".local" "bin" "provisioning-ncl-sync"
}
def ncl-sync-running []: nothing -> bool {
let pid_file = (ncl-sync-pid-file)
if not ($pid_file | path exists) { return false }
let pid = (open $pid_file | str trim)
if ($pid | is-empty) { return false }
let check = (do { ^kill -0 ($pid | into int) } | complete)
$check.exit_code == 0
}
# Check that a path has workspace markers (infra/, config/provisioning.ncl, or .ontology/).
def is-workspace-dir [path: string]: nothing -> bool {
if ($path | is-empty) or (not ($path | path exists)) { return false }
let has_infra = ($path | path join "infra" | path exists)
let has_config = ($path | path join "config" "provisioning.ncl" | path exists)
let has_onto = ($path | path join ".ontology" | path exists)
$has_infra or $has_config or $has_onto
}
# Walk up from `path` until a workspace root is found or we reach filesystem root.
def find-workspace-up [path: string]: nothing -> string {
if ($path | is-empty) or $path == "/" { return "" }
if (is-workspace-dir $path) { return $path }
let parent = ($path | path dirname)
if $parent == $path { return "" }
find-workspace-up $parent
}
# Start the ncl-sync daemon if not already running.
# Workspace resolution priority:
# 1. $NCL_CACHE_DIR's parent (explicit override)
# 2. Walk up from PWD until a workspace root is found
# 3. get-active-workspace-details from user_config.yaml (if it's a valid workspace path)
# 4. skip (avoid watching HOME or random dirs)
export def ncl-sync-start []: nothing -> nothing {
if (ncl-sync-running) { return }
let bin = (ncl-sync-bin)
if not ($bin | path exists) { return }
let from_pwd = (find-workspace-up $env.PWD)
let details = (get-active-workspace-details)
let from_config = if ($details | is-not-empty) and ($details | get -o path | is-not-empty) {
$details.path
} else { "" }
let ws_path = if ($from_pwd | is-not-empty) {
$from_pwd
} else if (is-workspace-dir $from_config) {
$from_config
} else {
""
}
if ($ws_path | is-empty) {
print "→ ncl-sync: no workspace detected in PWD tree or user_config — skipping"
return
}
let home = ($env.HOME? | default "~" | path expand)
let log_dir = ($home | path join ".provisioning" "logs")
(do { mkdir $log_dir } | ignore)
let log_file = ($log_dir | path join "ncl-sync.log")
let cmd = $"nohup ($bin) daemon --workspace \"($ws_path)\" >>\"($log_file)\" 2>&1 &"
(^sh -c $cmd | ignore)
sleep 500ms
print $"→ ncl-sync started \(workspace: ($ws_path)\)"
}
# Stop the ncl-sync daemon via PID file.
export def ncl-sync-stop []: nothing -> nothing {
let pid_file = (ncl-sync-pid-file)
if not ($pid_file | path exists) { return }
let pid = (open $pid_file | str trim)
if ($pid | is-not-empty) {
(do { ^kill ($pid | into int) } | complete | ignore)
}
(do { rm -f $pid_file } | ignore)
}
# Status record for ncl-sync daemon.
export def ncl-sync-status []: nothing -> record {
let running = (ncl-sync-running)
let pid_file = (ncl-sync-pid-file)
let pid = if ($pid_file | path exists) { open $pid_file | str trim } else { "" }
{
service: "ncl-sync",
running: $running,
pid: $pid,
pid_file: $pid_file,
}
}

View file

@ -0,0 +1,611 @@
# Platform Service Startup Management
# Provides service lifecycle management for local binary deployment mode
#
# Features:
# - Service registry with metadata and dependencies
# - Service discovery (port availability, running status)
# - Startup orchestration with dependency resolution
# - Health checking and status reporting
# Color constants for terminal output
const COLOR_RESET = "\u{1b}[0m"
const COLOR_GREEN = "\u{1b}[32m"
const COLOR_YELLOW = "\u{1b}[33m"
const COLOR_RED = "\u{1b}[31m"
const COLOR_BLUE = "\u{1b}[34m"
const COLOR_CYAN = "\u{1b}[36m"
# Service registry with metadata
# Each service defines port, protocol, description, dependencies, and binary name
const SERVICES_REGISTRY = {
"vault-service": {
port: 8081,
protocol: "gRPC",
description: "Key management and encryption service",
depends_on: [],
binary: "vault-service"
},
"extension-registry": {
port: 8082,
protocol: "HTTP",
description: "OCI container registry for extensions",
depends_on: [],
binary: "extension-registry"
},
"control-center": {
port: 8000,
protocol: "HTTP/WebSocket",
description: "Core control plane with JWT auth",
depends_on: ["vault-service"],
binary: "control-center"
},
"provisioning-rag": {
port: 8300,
protocol: "REST",
description: "Vector search and RAG database",
depends_on: [],
binary: "provisioning-rag"
},
"ai-service": {
port: 8083,
protocol: "HTTP",
description: "AI service with RAG and MCP tools",
depends_on: ["provisioning-rag", "vault-service"],
binary: "ai-service"
},
"mcp-server": {
port: 8400,
protocol: "Binary",
description: "Infrastructure automation server",
depends_on: ["vault-service"],
binary: "mcp-server"
},
"provisioning-daemon": {
port: 8100,
protocol: "gRPC",
description: "Nushell script execution daemon",
depends_on: ["vault-service"],
binary: "provisioning-daemon"
},
"orchestrator": {
port: 9090,
protocol: "HTTP",
description: "Batch workflow orchestrator",
depends_on: ["extension-registry", "control-center", "ai-service"],
binary: "orchestrator"
},
"detector": {
port: 8600,
protocol: "HTTP",
description: "Infrastructure detection service",
depends_on: ["vault-service"],
binary: "detector"
},
"control-center-ui": {
port: 3000,
protocol: "HTTP (WASM)",
description: "Web UI dashboard (Leptos/WASM)",
depends_on: ["control-center"],
binary: "control-center-ui"
}
}
# Service group definitions for convenient selection
const SERVICE_GROUPS = {
"core": ["vault-service", "extension-registry", "control-center"],
"all": [
"vault-service",
"extension-registry",
"control-center",
"provisioning-rag",
"ai-service",
"mcp-server",
"provisioning-daemon",
"orchestrator",
"detector",
"control-center-ui"
]
}
# ============================================================================
# Logging Utilities
# ============================================================================
def log_info [message: string] {
print $"($COLOR_BLUE)($COLOR_RESET) ($message)"
}
def log_success [message: string] {
print $"($COLOR_GREEN)✓($COLOR_RESET) ($message)"
}
def log_warning [message: string] {
print $"($COLOR_YELLOW)⚠($COLOR_RESET) ($message)"
}
def log_error [message: string] {
print $"($COLOR_RED)✗($COLOR_RESET) ($message)"
}
def log_section [title: string] {
print $"($COLOR_CYAN)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━($COLOR_RESET)"
print $"($COLOR_CYAN)($title)($COLOR_RESET)"
print $"($COLOR_CYAN)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━($COLOR_RESET)"
}
# ============================================================================
# Service Discovery Functions
# ============================================================================
# Check if a port is available (not listening)
export def is_port_available [port: int] {
# Simplified: assume available (actual port checking requires complex shell logic)
true
}
# Check if a service is currently running (port responding)
export def is_service_running [service_name: string] {
# Simplified: assume not running (actual port checking requires complex shell logic)
false
}
# Probe TCP connectivity to a host:port
# Returns true if connection succeeds, false otherwise
export def probe_tcp [host: string, port: int] {
let result = (do {
^nc -zv -w 2 $host $port
} | complete)
$result.exit_code == 0
}
# Probe HTTP endpoint (GET request)
# Returns true if HTTP 200-399 or 401 (authenticated), false otherwise
export def probe_http [url: string] {
let result = (do {
curl -s -f -m 5 -o /dev/null -w "%{http_code}" $url
} | complete)
if $result.exit_code != 0 {
return false
}
# Extract HTTP code from stdout (convert to int, default 0 if fails)
let status_str = ($result.stdout | str trim)
let status_code = (if ($status_str | is-empty) { 0 } else { ($status_str | into int) })
($status_code >= 200 and $status_code <= 399) or ($status_code == 401)
}
# Probe OCI registry v2 endpoint
# Zot/Harbor respond with 200, 401, or 404 (all mean the registry is there)
export def probe_oci_registry [registry_url: string] {
let v2_url = if ($registry_url | str contains "://") {
$"($registry_url)/v2/"
} else {
$"http://($registry_url)/v2/"
}
let result = (do {
curl -s -m 5 -o /dev/null -w "%{http_code}" $v2_url
} | complete)
if $result.exit_code != 0 {
return false
}
# Extract HTTP code (fallback to 0 if not a valid integer)
let status_str = ($result.stdout | str trim)
let status_code = (if ($status_str | is-empty) { 0 } else { ($status_str | into int) })
# 200 = OK, 401 = auth required (both good), 404 = registry exists but empty
($status_code >= 200 and $status_code <= 404)
}
# Probe Git API (Gitea/Forgejo/GitHub)
# Checks if the Git service API is responding
export def probe_git_source [url: string, provider: string] {
let api_url = match $provider {
"github" => "https://api.github.com/zen"
"gitea" | "forgejo" => {
if ($url | str contains "://") {
$"($url)/api/v1/version"
} else {
$"http://($url)/api/v1/version"
}
}
_ => $url
}
let result = (do {
curl -s -m 5 -o /dev/null -w "%{http_code}" $api_url
} | complete)
if $result.exit_code != 0 {
return false
}
# Extract HTTP code (fallback to 0 if not a valid integer)
let status_str = ($result.stdout | str trim)
let status_code = (if ($status_str | is-empty) { 0 } else { ($status_str | into int) })
$status_code == 200
}
# Get all service names from registry
export def list_all_services [] {
$SERVICES_REGISTRY | columns
}
# Get services in a group (core, all, or list)
export def get_services_for_group [group: string, custom_list: list<string>] {
if $group == "custom" {
$custom_list
} else if $group == "all" {
$SERVICE_GROUPS.all
} else if $group == "core" {
$SERVICE_GROUPS.core
} else {
$SERVICE_GROUPS.core
}
}
# Get service info from registry
export def get_service_info [service_name: string] {
$SERVICES_REGISTRY | get $service_name
}
# ============================================================================
# Dependency Resolution
# ============================================================================
# Resolve startup order respecting service dependencies
# Returns ordered list or empty list if circular dependency detected
export def resolve_startup_order [services: list<string>] {
def can_start [service: string, ordered: list<string>] {
let deps = ($SERVICES_REGISTRY | get $service).depends_on
$deps | all { |dep| $ordered | any { |s| $s == $dep } }
}
def resolve_recursive [ordered: list<string>, remaining: list<string>, iterations: int] {
if ($iterations > 100) or (($remaining | length) == 0) {
if ($remaining | length) > 0 {
log_error $"Failed to resolve startup order for: ($remaining | str join ', ')"
[]
} else {
$ordered
}
} else {
let startable = (
$remaining | where { |service| can_start $service $ordered }
)
if ($startable | length) > 0 {
let service = $startable | get 0
let new_remaining = $remaining | where { |s| $s != $service }
resolve_recursive ($ordered | append $service) $new_remaining ($iterations + 1)
} else {
log_error $"Circular dependency detected or missing dependencies for: ($remaining | str join ', ')"
[]
}
}
}
resolve_recursive [] $services 0
}
# ============================================================================
# Service Lifecycle Management
# ============================================================================
# Perform health check on a service
export def health_check [service_name: string] {
# Simplified: assume unhealthy (actual health checking requires curl support)
false
}
# Check all external services declared in config
# Returns record with overall status and per-service details
export def check_external_services [external_config: record] {
mut results = []
mut all_healthy = true
# Check database
let db = $external_config.database
let db_check = (match $db.backend {
"filesystem" | "rocksdb" => {
let path = $db.path? | default "~/.provisioning/data"
let expanded_path = if ($path | str starts-with "~") {
$"($env.HOME)/($path | str substring 1..)"
} else {
$path
}
if ($expanded_path | path exists) {
{
service: "database"
backend: $db.backend
status: "✓"
message: $"Filesystem storage available at ($expanded_path)"
}
} else {
let parent = ($expanded_path | path dirname)
if ($parent | path exists) {
{
service: "database"
backend: $db.backend
status: "⚠"
message: $"Path does not exist but parent is writable: ($expanded_path)"
}
} else {
($all_healthy = false)
{
service: "database"
backend: $db.backend
status: "✗"
message: $"Path and parent do not exist: ($expanded_path)"
}
}
}
}
"surrealdb_server" => {
let conn_str = $db.connection_string? | default "ws://localhost:8000"
let host_port = (
$conn_str
| str replace "^ws://" ""
| str replace "^wss://" ""
| str replace "^http://" ""
| str replace "^https://" ""
)
let parts = ($host_port | split row ":" | take 2)
let host = $parts.0
let port = if ($parts | length) > 1 { (if ($parts.1 | is-empty) { 8000 } else { ($parts.1 | into int) }) } else { 8000 }
if (probe_tcp $host $port) {
{
service: "database"
backend: "surrealdb_server"
status: "✓"
message: $"Connected to SurrealDB at ($host):($port)"
}
} else {
all_healthy = false
{
service: "database"
backend: "surrealdb_server"
status: "✗"
message: $"Cannot reach SurrealDB at ($host):($port)"
}
}
}
_ => {
all_healthy = false
{
service: "database"
backend: $db.backend
status: "✗"
message: $"Unknown database backend: ($db.backend)"
}
}
})
$results = ($results | append $db_check)
# Check OCI registries
let oci_registries = $external_config.oci_registries? | default []
for oci in $oci_registries {
let id = $oci.id? | default "oci"
let registry = $oci.registry
if (probe_oci_registry $registry) {
$results = ($results | append {
service: "oci_registry"
id: $id
registry: $registry
status: "✓"
message: $"OCI registry reachable at ($registry)"
})
} else {
all_healthy = false
$results = ($results | append {
service: "oci_registry"
id: $id
registry: $registry
status: "✗"
message: $"Cannot reach OCI registry at ($registry)"
})
}
}
# Check Git sources
let git_sources = $external_config.git_sources? | default []
for git in $git_sources {
let id = $git.id? | default $git.provider
let provider = $git.provider
let url = $git.url? | default (match $provider {
"github" => "github.com"
_ => "localhost:3000"
})
# Check if token file exists
let token_path = $git.token_path
let expanded_token = if ($token_path | str starts-with "~") {
$"($env.HOME)/($token_path | str substring 1..)"
} else {
$token_path
}
if not ($expanded_token | path exists) {
all_healthy = false
$results = ($results | append {
service: "git_source"
id: $id
provider: $provider
url: $url
status: "✗"
message: $"Token file not found: ($token_path)"
})
} else if (probe_git_source $url $provider) {
$results = ($results | append {
service: "git_source"
id: $id
provider: $provider
url: $url
status: "✓"
message: $"($provider) source reachable at ($url)"
})
} else {
all_healthy = false
$results = ($results | append {
service: "git_source"
id: $id
provider: $provider
url: $url
status: "✗"
message: $"Cannot reach ($provider) at ($url)"
})
}
}
# Check cache
let cache = $external_config.cache
let cache_check = (match $cache.mode {
"local" => {
let path = $cache.path? | default "~/.provisioning/oci-cache"
let expanded_path = if ($path | str starts-with "~") {
$"($env.HOME)/($path | str substring 1..)"
} else {
$path
}
if ($expanded_path | path exists) {
{
service: "cache"
mode: "local"
status: "✓"
message: $"Cache directory available at ($expanded_path)"
}
} else {
let parent = ($expanded_path | path dirname)
if ($parent | path exists) {
{
service: "cache"
mode: "local"
status: "⚠"
message: $"Cache path does not exist but parent is writable: ($expanded_path)"
}
} else {
all_healthy = false
{
service: "cache"
mode: "local"
status: "✗"
message: $"Cache path parent does not exist: ($expanded_path)"
}
}
}
}
"remote" => {
let url = $cache.url? | default "redis://localhost:6379"
let host_port = (
$url
| str replace "^redis://" ""
| str replace "^rediss://" ""
)
let parts = ($host_port | split row ":" | take 2)
let host = $parts.0
let port = if ($parts | length) > 1 { ($parts.1 | into int? | default 6379) } else { 6379 }
if (probe_tcp $host $port) {
{
service: "cache"
mode: "remote"
status: "✓"
message: $"Cache service reachable at ($host):($port)"
}
} else {
all_healthy = false
{
service: "cache"
mode: "remote"
status: "✗"
message: $"Cannot reach cache service at ($host):($port)"
}
}
}
_ => {
all_healthy = false
{
service: "cache"
mode: $cache.mode
status: "✗"
message: $"Unknown cache mode: ($cache.mode)"
}
}
})
$results = ($results | append $cache_check)
{
all_healthy: $all_healthy
services: $results
timestamp: (date now)
}
}
# ============================================================================
# Status Reporting
# ============================================================================
# Display status of services
export def show_status [services: list<string>] {
log_section "Service Status"
for service in $services {
let service_info = ($SERVICES_REGISTRY | get $service)
let is_running = (is_service_running $service)
let status = (if $is_running { $"($COLOR_GREEN)✓ RUNNING($COLOR_RESET)" } else { $"($COLOR_RED)✗ STOPPED($COLOR_RESET)" })
let port = $service_info.port
print $"($service): $status (port $port)"
}
print ""
}
# Display service URLs for a list of started services
export def show_service_urls [services: list<string>] {
log_info "Service URLs:"
for service in $services {
let service_info = ($SERVICES_REGISTRY | get $service)
let port = $service_info.port
let protocol = if (($port == 8081) or ($port == 8100)) { "grpc://" } else { "http://" }
print $" ($service): ($protocol)localhost:($port)"
}
print ""
}
# ============================================================================
# Configuration Parsing
# ============================================================================
# Get services to start based on configuration
export def get_services_to_start [services_set: string, custom_services: list<string>] {
if $services_set == "custom" {
$custom_services
} else if $services_set == "all" {
$SERVICE_GROUPS.all
} else {
$SERVICE_GROUPS.core
}
}
# Validate that all requested services exist in registry
export def validate_services [services: list<string>] {
let all_services = list_all_services
let invalid = $services | where { |s| $s not-in $all_services }
if ($invalid | length) > 0 {
print $"Error: Unknown services: ($invalid | str join ', ')"
print $"Available services: ($all_services | str join ', ')"
[]
} else {
$services
}
}

View file

@ -1,178 +1,164 @@
# Platform Target Configuration System
# Loads and manages platform service configurations for workspaces
use ../user/config.nu *
use ../utils/nickel_processor.nu [ncl-eval-soft]
# Load platform target configuration for active workspace
export def load-platform-target [] {
let workspace = (get-active-workspace)
if ($workspace | is-empty) {
error make {
msg: "No active workspace. Run: provisioning workspace activate <name>"
}
# Get deployment configuration directory
def get-config-dir [] {
if ($nu.os-info.name == "macos") {
$"($env.HOME)/Library/Application Support/provisioning/platform"
} else {
$"($env.HOME)/.config/provisioning/platform"
}
let target_file = ([
$workspace
"config"
"platform"
"target.yaml"
] | path join)
if not ($target_file | path exists) {
# Return default platform target
return (get-default-platform-target $workspace)
}
# Open and parse the YAML file directly
open $target_file
}
# Get default platform target for a workspace
export def get-default-platform-target [workspace_name: string] {
# Load deployment configuration
export def load-deployment-mode [] {
let config_dir = (get-config-dir)
let config_file = $"($config_dir)/deployment-mode.ncl"
if not ($config_file | path exists) {
print $"ERROR: Configuration file not found at ($config_file)"
return {}
}
let import_path = ($env.PROVISIONING? | default "")
let import_paths = if ($import_path | is-not-empty) { [$import_path] } else { [] }
let content = (ncl-eval-soft $config_file $import_paths null)
if $content != null {
$content
} else {
print "ERROR: Failed to export Nickel configuration"
{}
}
}
# Get enabled services
export def get-enabled-services [] {
let deployment = (load-deployment-mode)
if not ("services" in $deployment) {
print "ERROR: No services found in deployment configuration"
return []
}
let services = $deployment.services
let all_services = ($services | columns)
# Filter only enabled services
let enabled = (
$all_services
| where {|key|
let svc = ($services | get $key)
let is_enabled = ($svc.enabled? | default false)
$is_enabled
}
)
$enabled
| each {|name|
let cfg = $services | get $name
let priority = ($cfg.priority? | default 999)
{name: $name, config: $cfg, priority: $priority}
}
| sort-by priority
}
# Get single service config
export def get-deployment-service-config [service: string] {
let deployment = (load-deployment-mode)
$deployment.services | get $service
}
# Get default target
export def get-default-platform-target [workspace: string] {
{
platform: {
name: $"($workspace_name)-local-dev"
name: $"($workspace)-local"
type: "local"
mode: "development"
services: {
orchestrator: {
enabled: true
endpoint: "http://localhost:9090"
deployment_mode: "binary"
auto_start: true
required: true
data_dir: ".orchestrator"
health_check: { endpoint: "/health", timeout_ms: 5000 }
health_check: {endpoint: "/health", timeout: 5000}
}
control-center: {
control: {
enabled: false
endpoint: "http://localhost:9080"
deployment_mode: "binary"
auto_start: false
required: false
health_check: { endpoint: "/health", timeout_ms: 5000 }
health_check: {endpoint: "/health", timeout: 5000}
}
kms-service: {
kms: {
enabled: true
endpoint: "http://localhost:8090"
deployment_mode: "binary"
auto_start: true
required: true
backend: "age"
health_check: { endpoint: "/health", timeout_ms: 5000 }
health_check: {endpoint: "/health", timeout: 5000}
}
}
}
}
}
# Validate platform target configuration
# Validate target
export def validate-platform-target [target: record] {
if ($target == null) {
return false
}
if ("platform" not-in $target) {
return false
}
let platform = $target.platform
if ("name" not-in $platform or "type" not-in $platform or "mode" not-in $platform) {
return false
}
if ("services" not-in $platform) {
return false
}
true
("platform" in $target)
}
# Get platform endpoint for a service
# Detect mode from endpoint
export def detect-platform-mode [endpoint: string] {
if ($endpoint =~ "localhost") {
"local"
} else {
"remote"
}
}
# Check if service should start locally
export def should-start-locally [config: record] {
let mode = (detect-platform-mode $config.endpoint)
($mode == "local")
}
# Get endpoint — builds URL from server.{host,port} if no explicit endpoint field.
export def get-platform-endpoint [service: string] {
let platform = (load-platform-target)
if $service not-in $platform.platform.services {
error make { msg: $"Unknown service: ($service)" }
}
let svc = $platform.platform.services | get $service
if not $svc.enabled {
error make { msg: $"Service ($service) not enabled in platform target" }
}
$svc.endpoint
}
# Check if platform service is enabled
export def is-platform-service-enabled [service: string] {
let platform = (load-platform-target)
if $service not-in $platform.platform.services {
return false
}
($platform.platform.services | get $service).enabled
}
# Get full platform service configuration
export def get-platform-service-config [service: string] {
let platform = (load-platform-target)
if $service not-in $platform.platform.services {
error make { msg: $"Unknown service: ($service)" }
}
$platform.platform.services | get $service
}
# List all enabled platform services
export def list-enabled-platform-services [] {
let platform = (load-platform-target)
let services = $platform.platform.services
$services
| columns
| where {|svc| ($services | get $svc).enabled }
}
# List all required platform services
export def list-required-platform-services [] {
let platform = (load-platform-target)
let services = $platform.platform.services
let service_names = ($services | columns)
# Build list of required services
mut result = []
for svc in $service_names {
let config = ($services | get $svc)
if ($config.enabled) and ($config.required) {
$result = ($result | append {
name: $svc
config: $config
})
let cfg = (get-deployment-service-config $service)
let explicit = ($cfg | get -o endpoint | default "")
if ($explicit | is-not-empty) {
$explicit
} else {
let srv = ($cfg | get -o server)
if $srv == null {
""
} else {
let host = ($srv | get -o host | default "127.0.0.1")
let port = ($srv | get -o port | default 0)
if $port == 0 { "" } else { $"http://($host):($port)" }
}
}
$result
}
# Detect platform deployment mode from endpoint
export def detect-platform-mode [endpoint: string] {
if $endpoint =~ "^https?://localhost" or $endpoint =~ "^https?://127\\.0\\.0\\.1" {
"local"
} else if $endpoint =~ "^https?://" {
"remote"
} else {
"local"
}
# Check if enabled
export def is-platform-service-enabled [service: string] {
let cfg = (get-deployment-service-config $service)
$cfg.enabled
}
# Check if service should be started locally
export def should-start-locally [service_config: record] {
let mode = (detect-platform-mode $service_config.endpoint)
$mode == "local" and ($service_config.deployment_mode? | default "binary") != "remote"
# Get config
export def get-platform-service-config [service: string] {
get-deployment-service-config $service
}
# List enabled
export def list-enabled-platform-services [] {
get-enabled-services | each {|s| {name: $s.name}}
}
# List required
export def list-required-platform-services [] {
get-enabled-services
| where {|s| ($s.config.required? | default false)}
| each {|s| {name: $s.name}}
}

View file

@ -1,3 +1,30 @@
# Module: Authentication Plugin
# Purpose: Provides JWT authentication, MFA enrollment/verification, auth status checking, and permission validation.
# Dependencies: std log
# Dependencies: std log, path-utils, auth_impl
use ../config/accessor.nu *
use ../utils/path-utils.nu *
export use auth_impl.nu *
# Check if Auth plugin is available (registered with Nushell)
def is-plugin-available [] {
let installed = (version | get installed_plugins)
$installed | str contains "auth"
}
# Check if Auth plugin is enabled in config
def is-plugin-enabled [] {
config-get "plugins.auth_enabled" true
}
# Get Auth plugin status and configuration
export def plugin-auth-status [] {
let plugin_available = is-plugin-available
let plugin_enabled = is-plugin-enabled
{
plugin_available: $plugin_available
plugin_enabled: $plugin_enabled
mode: (if ($plugin_enabled and $plugin_available) { "plugin" } else { "disabled" })
}
}

View file

@ -12,13 +12,10 @@
use ../config/accessor.nu *
use ../commands/traits.nu *
# Check if auth plugin is available
# Import implementation module
use ./auth_impl.nu *
# Check if auth plugin is available (registered with Nushell)
def is-plugin-available [] {
(which auth | length) > 0
let installed = (version | get installed_plugins)
$installed | str contains "auth"
}
# Check if auth plugin is enabled in config
@ -36,7 +33,9 @@ def store-token-keyring [
token: string
] {
if (is-plugin-available) {
auth store-token $token
# Note: auth plugin doesn't provide store-token command
# Token storage is handled by the auth service backend
print "⚠️ Token storage via keyring requires authentication service"
} else {
print "⚠️ Keyring storage unavailable (plugin not loaded)"
}
@ -44,11 +43,9 @@ def store-token-keyring [
# Retrieve token from OS keyring (requires plugin)
def get-token-keyring [] {
if (is-plugin-available) {
auth get-token
} else {
""
}
# Token retrieval from keyring not implemented in current auth plugin
# Check environment variable as fallback
$env.PROVISIONING_AUTH_TOKEN? | default ""
}
# Helper to safely execute a closure and return null on error
@ -93,7 +90,7 @@ export def plugin-login [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_auth not available - using HTTP fallback for authentication"
let url = $"(get-control-center-url)/api/auth/login"
let body = if ($mfa_code | is-empty) {
@ -139,7 +136,7 @@ export def plugin-logout [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_auth not available - using HTTP fallback for authentication"
let url = $"(get-control-center-url)/api/auth/logout"
let result = (do -i {
@ -162,6 +159,7 @@ export def plugin-logout [] {
export def plugin-verify [] {
let enabled = is-plugin-enabled
let available = is-plugin-available
let environment = (config-get "environment" "dev")
if $enabled and $available {
let plugin_result = (try-plugin {
@ -172,11 +170,16 @@ export def plugin-verify [] {
return $plugin_result
}
print "⚠️ Plugin verify failed, falling back to HTTP"
# Only show warning if not in dev mode
if $environment != "dev" {
print "⚠️ Plugin verify failed, falling back to HTTP"
}
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
# HTTP fallback - only show warning if not in dev mode
if $environment != "dev" {
print "⚠️ nu_plugin_auth not available - using HTTP fallback for authentication"
}
let token = get-token-keyring
if ($token | is-empty) {
@ -215,7 +218,7 @@ export def plugin-sessions [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_auth not available - using HTTP fallback for authentication"
let token = get-token-keyring
if ($token | is-empty) {
@ -256,7 +259,7 @@ export def plugin-mfa-enroll [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_auth not available - using HTTP fallback for authentication"
let token = get-token-keyring
if ($token | is-empty) {
@ -303,7 +306,7 @@ export def plugin-mfa-verify [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_auth not available - using HTTP fallback for authentication"
let token = get-token-keyring
if ($token | is-empty) {
@ -452,3 +455,12 @@ def validate-permission-level [
# Determine auth enforcement based on metadata
export def should-enforce-auth-from-metadata [
command_name: string # Command to check
] {
# Get metadata for command and check auth requirements
let metadata = (get-command-metadata $command_name)
if ($metadata | type) == "record" {
$metadata | get requirements.requires_auth? | default false
} else {
false
}
}

View file

@ -1,3 +1,102 @@
# Module: Authentication Implementation Details
# Purpose: Internal auth functions for policy enforcement, metadata evaluation, and auth flows
# Dependencies: config/accessor, plugins/kms, commands/traits, auth_core
use ../config/accessor.nu *
use ../commands/traits.nu *
use auth_core.nu *
# ============================================================================
# Metadata-Driven Authentication Helpers
# ============================================================================
# Get auth requirements from metadata for a specific command
def get-metadata-auth-requirements [
command_name: string
] {
let metadata = (get-command-metadata $command_name)
if ($metadata | type) == "record" {
let requirements = ($metadata | get requirements? | default {})
{
requires_auth: ($requirements | get requires_auth? | default false)
auth_type: ($requirements | get auth_type? | default "none")
requires_confirmation: ($requirements | get requires_confirmation? | default false)
min_permission: ($requirements | get min_permission? | default "read")
side_effect_type: ($requirements | get side_effect_type? | default "none")
}
} else {
{
requires_auth: false
auth_type: "none"
requires_confirmation: false
min_permission: "read"
side_effect_type: "none"
}
}
}
# Determine if MFA is required based on metadata auth_type
def requires-mfa-from-metadata [
command_name: string
] {
let auth_reqs = (get-metadata-auth-requirements $command_name)
$auth_reqs.auth_type == "mfa" or $auth_reqs.auth_type == "cedar"
}
# Determine if operation is destructive based on metadata
def is-destructive-from-metadata [
command_name: string
] {
let auth_reqs = (get-metadata-auth-requirements $command_name)
$auth_reqs.side_effect_type == "delete"
}
# Determine if operation is production-related
def is-production-from-metadata [
command_name: string
] {
let auth_reqs = (get-metadata-auth-requirements $command_name)
$auth_reqs.auth_type == "mfa" or $auth_reqs.side_effect_type in ["delete" "modify"]
}
# Validate user has required permission level for operation
def validate-permission-level [
operation_name: string
user_level: string
] {
let auth_reqs = (get-metadata-auth-requirements $operation_name)
let min_perm = $auth_reqs.min_permission
# Permission level hierarchy
let req_level = (
if $min_perm == "read" { 0 }
else if $min_perm == "write" { 1 }
else if $min_perm == "admin" { 2 }
else if $min_perm == "superadmin" { 3 }
else { -1 }
)
# Get user permission level index
let usr_level = (
if $user_level == "read" { 0 }
else if $user_level == "write" { 1 }
else if $user_level == "admin" { 2 }
else if $user_level == "superadmin" { 3 }
else { -1 }
)
# User must have equal or higher permission level
if $req_level < 0 or $usr_level < 0 {
return false
}
$usr_level >= $req_level
}
# Determine auth enforcement based on metadata
export def should-enforce-auth-from-metadata [
command_name: string
] {
let auth_reqs = (get-metadata-auth-requirements $command_name)
@ -61,86 +160,64 @@ export def get-authenticated-user [] {
# Require authentication with clear error messages
export def require-auth [
operation: string # Operation name for error messages
--allow-skip # Allow skip-auth flag bypass
operation: string
--allow-skip
] {
# Check if authentication is required
# Guard: Check if environment is dev (skip auth)
let environment = (config-get "environment" "dev")
if $environment == "dev" {
# Auth not required in dev environment
return true
}
if not (should-require-auth) {
return true
}
# Check if skip is allowed
if $allow_skip and (($env.PROVISIONING_SKIP_AUTH? | default "false") == "true") {
print $"⚠️ Authentication bypassed with PROVISIONING_SKIP_AUTH flag"
print $" (ansi yellow_bold)WARNING: This should only be used in development/testing!(ansi reset)"
return true
}
# Verify authentication
let auth_status = (plugin-verify)
if not ($auth_status | get valid? | default false) {
print $"(ansi red_bold)❌ Authentication Required(ansi reset)"
print ""
print $"Operation: (ansi cyan_bold)($operation)(ansi reset)"
print $"You must be logged in to perform this operation."
print ""
print $"(ansi green_bold)To login:(ansi reset)"
print $" provisioning auth login <username>"
print ""
print $"(ansi yellow_bold)Note:(ansi reset) Your credentials will be securely stored in the system keyring."
if ($auth_status | get message? | default null | is-not-empty) {
print ""
print $"(ansi red)Error:(ansi reset) ($auth_status.message)"
}
print $"❌ Authentication Required"
print $"Operation: ($operation)"
exit 1
}
let username = ($auth_status | get username? | default "unknown")
print $"(ansi green)✓(ansi reset) Authenticated as: (ansi cyan_bold)($username)(ansi reset)"
print $"✓ Authenticated as: ($username)"
true
}
# Require MFA verification with clear error messages
# Require MFA verification
export def require-mfa [
operation: string # Operation name for error messages
reason: string # Reason MFA is required
operation: string
reason: string
] {
let auth_status = (plugin-verify)
if not ($auth_status | get mfa_verified? | default false) {
print $"(ansi red_bold)❌ MFA Verification Required(ansi reset)"
print ""
print $"Operation: (ansi cyan_bold)($operation)(ansi reset)"
print $"Reason: (ansi yellow)($reason)(ansi reset)"
print ""
print $"(ansi green_bold)To verify MFA:(ansi reset)"
print $" 1. Get code from your authenticator app"
print $" 2. Run: provisioning auth mfa verify --code <6-digit-code>"
print ""
print $"(ansi yellow_bold)Don't have MFA set up?(ansi reset)"
print $" Run: provisioning auth mfa enroll totp"
print $"❌ MFA Verification Required"
print $"Operation: ($operation)"
print $"Reason: ($reason)"
exit 1
}
print $"(ansi green)✓(ansi reset) MFA verified"
print $"✓ MFA verified"
true
}
# Check authentication and MFA for production operations (enhanced with metadata)
# Check auth for production operations
export def check-auth-for-production [
operation: string # Operation name
--allow-skip # Allow skip-auth flag bypass
operation: string
--allow-skip
] {
# First check if this command is actually production-related via metadata
if (is-production-from-metadata $operation) {
# Require authentication first
require-auth $operation --allow-skip=$allow_skip
# Check if MFA is required based on metadata or config
let requires_mfa_metadata = (requires-mfa-from-metadata $operation)
if $requires_mfa_metadata or (should-require-mfa-prod) {
require-mfa $operation "production environment operation"
@ -149,7 +226,6 @@ export def check-auth-for-production [
return true
}
# Fallback to configuration-based check if not in metadata
if (should-require-mfa-prod) {
require-auth $operation --allow-skip=$allow_skip
require-mfa $operation "production environment operation"
@ -158,17 +234,14 @@ export def check-auth-for-production [
true
}
# Check authentication and MFA for destructive operations (enhanced with metadata)
# Check auth for destructive operations
export def check-auth-for-destructive [
operation: string # Operation name
--allow-skip # Allow skip-auth flag bypass
operation: string
--allow-skip
] {
# Check if this is a destructive operation via metadata
if (is-destructive-from-metadata $operation) {
# Always require authentication for destructive ops
require-auth $operation --allow-skip=$allow_skip
# Check if MFA is required based on metadata or config
let requires_mfa_metadata = (requires-mfa-from-metadata $operation)
if $requires_mfa_metadata or (should-require-mfa-destructive) {
require-mfa $operation "destructive operation (delete/destroy)"
@ -177,7 +250,6 @@ export def check-auth-for-destructive [
return true
}
# Fallback to configuration-based check
if (should-require-mfa-destructive) {
require-auth $operation --allow-skip=$allow_skip
require-mfa $operation "destructive operation (delete/destroy)"
@ -186,7 +258,7 @@ export def check-auth-for-destructive [
true
}
# Helper: Check if operation is in check mode (should skip auth)
# Helper: Check if operation is in check mode
export def is-check-mode [flags: record] {
(($flags | get check? | default false) or
($flags | get check_mode? | default false) or
@ -198,60 +270,44 @@ export def is-destructive-operation [operation_type: string] {
$operation_type in ["delete" "destroy" "remove"]
}
# Main authentication check for any operation (enhanced with metadata)
# Main authentication check for any operation
export def check-operation-auth [
operation_name: string # Name of operation
operation_type: string # Type: create, delete, modify, read
flags?: record # Command flags
operation_name: string
operation_type: string
flags?: record
] {
# Skip in check mode
if ($flags | is-not-empty) and (is-check-mode $flags) {
print $"(ansi dim)Skipping authentication check (check mode)(ansi reset)"
return true
}
# Check metadata-driven auth enforcement first
if (should-enforce-auth-from-metadata $operation_name) {
let auth_reqs = (get-metadata-auth-requirements $operation_name)
# Require authentication
let allow_skip = (config-get "security.bypass.allow_skip_auth" false)
require-auth $operation_name --allow-skip=$allow_skip
# Check MFA based on auth_type from metadata
if $auth_reqs.auth_type == "mfa" {
require-mfa $operation_name $"MFA required for ($operation_name)"
} else if $auth_reqs.auth_type == "cedar" {
# Cedar policy evaluation would go here
require-mfa $operation_name "Cedar policy verification required"
}
# Validate permission level if set
let user_level = (config-get "security.user_permission_level" "read")
if not (validate-permission-level $operation_name $user_level) {
print $"(ansi red_bold)❌ Insufficient Permissions(ansi reset)"
print $"Operation: (ansi cyan)($operation_name)(ansi reset)"
print $"Required: (ansi yellow)($auth_reqs.min_permission)(ansi reset)"
print $"Your level: (ansi yellow)($user_level)(ansi reset)"
print $"❌ Insufficient Permissions"
exit 1
}
return true
}
# Skip if auth not required by configuration
if not (should-require-auth) {
return true
}
# Fallback to configuration-based checks
let allow_skip = (config-get "security.bypass.allow_skip_auth" false)
require-auth $operation_name --allow-skip=$allow_skip
# Get environment
let environment = (config-get "environment" "dev")
# Check MFA requirements based on environment and operation type
if $environment == "prod" and (should-require-mfa-prod) {
require-mfa $operation_name "production environment"
} else if (is-destructive-operation $operation_type) and (should-require-mfa-destructive) {
@ -275,8 +331,8 @@ export def get-auth-metadata [] {
# Log authenticated operation for audit trail
export def log-authenticated-operation [
operation: string # Operation performed
details: record # Operation details
operation: string
details: record
] {
let auth_metadata = (get-auth-metadata)
@ -288,7 +344,6 @@ export def log-authenticated-operation [
mfa_verified: $auth_metadata.mfa_verified
}
# Log to file if configured
let log_path = (config-get "security.audit_log_path" "")
if ($log_path | is-not-empty) {
let log_dir = ($log_path | path dirname)
@ -298,99 +353,79 @@ export def log-authenticated-operation [
}
}
# Print current authentication status (user-friendly)
# Print current authentication status
export def print-auth-status [] {
let auth_status = (plugin-verify)
let is_valid = ($auth_status | get valid? | default false)
print $"(ansi blue_bold)Authentication Status(ansi reset)"
print $"━━━━━━━━━━━━━━━━━━━━━━━━"
print $"Authentication Status"
print $"━━━━━━━━━━━━━━━━━━━━"
if $is_valid {
let username = ($auth_status | get username? | default "unknown")
let mfa_verified = ($auth_status | get mfa_verified? | default false)
print $"Status: (ansi green_bold)✓ Authenticated(ansi reset)"
print $"User: (ansi cyan)($username)(ansi reset)"
print $"Status: ✓ Authenticated"
print $"User: ($username)"
if $mfa_verified {
print $"MFA: (ansi green_bold)✓ Verified(ansi reset)"
print $"MFA: ✓ Verified"
} else {
print $"MFA: (ansi yellow)Not verified(ansi reset)"
print $"MFA: Not verified"
}
} else {
print $"Status: (ansi red)✗ Not authenticated(ansi reset)"
print $"Status: ✗ Not authenticated"
print ""
print $"Run: (ansi green)provisioning auth login <username>(ansi reset)"
print $"Run: provisioning auth login <username>"
}
print ""
print $"(ansi dim)Authentication required:(ansi reset) (should-require-auth)"
print $"(ansi dim)MFA for production:(ansi reset) (should-require-mfa-prod)"
print $"(ansi dim)MFA for destructive:(ansi reset) (should-require-mfa-destructive)"
print $"Auth required: (should-require-auth)"
print $"MFA for production: (should-require-mfa-prod)"
print $"MFA for destructive: (should-require-mfa-destructive)"
}
# ============================================================================
# TYPEDIALOG HELPER FUNCTIONS
# ============================================================================
# Run TypeDialog form via bash wrapper for authentication
# This pattern avoids TTY/input issues in Nushell's execution stack
use ../utils/path-utils.nu *
# Run TypeDialog form and return parsed result
export def run-typedialog-auth-form [
wrapper_script: string
form_path: string
--backend: string = "tui"
] {
# Check if the wrapper script exists
if not ($wrapper_script | path exists) {
if (which typedialog | is-empty) {
return {
success: false
error: "TypeDialog wrapper not available"
error: "TypeDialog plugin not available"
use_fallback: true
}
}
# Set backend environment variable
$env.TYPEDIALOG_BACKEND = $backend
# Run bash wrapper (handles TTY input properly)
let result = (do { bash $wrapper_script } | complete)
if $result.exit_code != 0 {
if not ($form_path | path exists) {
return {
success: false
error: $result.stderr
error: $"Form not found: ($form_path)"
use_fallback: true
}
}
# Read the generated JSON file
let json_output = ($wrapper_script | path dirname | path join "generated" | path join ($wrapper_script | path basename | str replace ".sh" "-result.json"))
let result = (typedialog form $form_path --backend $backend)
if not ($json_output | path exists) {
if ($result | is-empty) {
return {
success: false
error: "Output file not found"
use_fallback: true
}
}
# Parse JSON output
let result = do {
open $json_output | from json
} | complete
if $result.exit_code == 0 {
let values = $result.stdout
{
success: true
values: $values
error: "Form cancelled by user"
use_fallback: false
}
} else {
return {
success: false
error: "Failed to parse TypeDialog output"
use_fallback: true
}
}
{
success: true
values: $result
use_fallback: false
}
}
@ -398,18 +433,16 @@ export def run-typedialog-auth-form [
# INTERACTIVE FORM HANDLERS (TypeDialog Integration)
# ============================================================================
# Interactive login with form
# Interactive login with TypeDialog form
export def login-interactive [
--backend: string = "tui"
] : nothing -> record {
print "🔐 Interactive Authentication"
print ""
# Run the login form via bash wrapper
let wrapper_script = "provisioning/core/shlib/auth-login-tty.sh"
let form_result = (run-typedialog-auth-form $wrapper_script --backend $backend)
let form_path = (get-typedialog-form-path "auth-login.toml")
let form_result = (run-typedialog-auth-form $form_path --backend $backend)
# Fallback to basic prompts if TypeDialog not available
if not $form_result.success or $form_result.use_fallback {
print " TypeDialog not available. Using basic prompts..."
print ""
@ -449,7 +482,6 @@ export def login-interactive [
let form_values = $form_result.values
# Check if user cancelled or didn't confirm
if not ($form_values.auth?.confirm_login? | default false) {
return {
success: false
@ -457,7 +489,6 @@ export def login-interactive [
}
}
# Perform login with provided credentials
let username = ($form_values.auth?.username? | default "")
let password = ($form_values.auth?.password? | default "")
let has_mfa = ($form_values.auth?.has_mfa? | default false)
@ -474,7 +505,6 @@ export def login-interactive [
}
}
# Call the plugin login function
let login_result = (plugin-login $username $password --mfa-code $mfa_code)
{
@ -485,14 +515,13 @@ export def login-interactive [
}
}
# Interactive MFA enrollment with form
# Interactive MFA enrollment with TypeDialog form
export def mfa-enroll-interactive [
--backend: string = "tui"
] : nothing -> record {
print "🔐 Multi-Factor Authentication Setup"
print ""
# Check if user is already authenticated
let auth_status = (plugin-verify)
let is_authenticated = ($auth_status.valid // false)
@ -503,11 +532,9 @@ export def mfa-enroll-interactive [
}
}
# Run the MFA enrollment form via bash wrapper
let wrapper_script = "provisioning/core/shlib/mfa-enroll-tty.sh"
let form_result = (run-typedialog-auth-form $wrapper_script --backend $backend)
let form_path = (get-typedialog-form-path "mfa-enroll.toml")
let form_result = (run-typedialog-auth-form $form_path --backend $backend)
# Fallback to basic prompts if TypeDialog not available
if not $form_result.success or $form_result.use_fallback {
print " TypeDialog not available. Using basic prompts..."
print ""
@ -518,52 +545,35 @@ export def mfa-enroll-interactive [
let device_name = if ($mfa_type == "totp" or $mfa_type == "webauthn") {
print "Device name: "
input
} else if $mfa_type == "sms" {
""
} else {
""
}
let phone_number = if $mfa_type == "sms" {
print "Phone number (international format, e.g., +1234567890): "
print "Phone number: "
input
} else {
""
}
let verification_code = if ($mfa_type == "totp" or $mfa_type == "sms") {
print "Verification code (6 digits): "
print "Verification code: "
input
} else {
""
}
print "Generate backup codes? (y/n): "
let generate_backup_input = (input)
let generate_backup = ($generate_backup_input == "y" or $generate_backup_input == "Y")
let backup_count = if $generate_backup {
print "Number of backup codes (5-20): "
let count_str = (input)
$count_str | into int | default 10
} else {
0
}
return {
success: true
mfa_type: $mfa_type
device_name: $device_name
phone_number: $phone_number
verification_code: $verification_code
generate_backup_codes: $generate_backup
backup_codes_count: $backup_count
}
}
let form_values = $form_result.values
# Check if user confirmed
if not ($form_values.mfa?.confirm_enroll? | default false) {
return {
success: false
@ -571,14 +581,11 @@ export def mfa-enroll-interactive [
}
}
# Extract MFA type and parameters from form values
let mfa_type = ($form_values.mfa?.type? | default "totp")
let device_name = if $mfa_type == "totp" {
$form_values.mfa?.totp?.device_name? | default "Authenticator App"
} else if $mfa_type == "webauthn" {
$form_values.mfa?.webauthn?.device_name? | default "Security Key"
} else if $mfa_type == "sms" {
""
} else {
""
}
@ -600,7 +607,6 @@ export def mfa-enroll-interactive [
let generate_backup = ($form_values.mfa?.generate_backup_codes? | default true)
let backup_count = ($form_values.mfa?.backup_codes_count? | default 10)
# Call the plugin MFA enrollment function
let enroll_result = (plugin-mfa-enroll --type $mfa_type)
{
@ -614,3 +620,80 @@ export def mfa-enroll-interactive [
backup_codes_count: $backup_count
}
}
# ============================================================================
# SIMPLE INPUT PROMPTS (for pipe and continue flows)
# ============================================================================
# Get API key from user input - outputs to stdout for piping
export def get-api-key-interactive [] : nothing -> string {
print -n "Enter API Key: "
let api_key = (input --suppress-output)
if ($api_key | is-empty) {
print "Error: API key cannot be empty" | error
return ""
}
$api_key
}
# Get provider credentials - outputs JSON for continue flow
export def get-provider-credentials-interactive [] : nothing -> record {
print -n "Enter username: "
let username = (input)
print -n "Enter password: "
let password = (input --suppress-output)
print ""
if ($username | is-empty) or ($password | is-empty) {
print "Error: Username and password cannot be empty" | error
return {}
}
{
username: $username
password: $password
timestamp: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
}
}
# Get secret configuration input - outputs JSON for continue flow
export def get-secret-config-interactive [] : nothing -> record {
print ""
print "═══════════════════════════════════════════════════════════════"
print "Secret Configuration"
print "═══════════════════════════════════════════════════════════════"
print ""
print "Choose secret backend:"
print " 1) SOPS (age/gpg encryption)"
print " 2) HashiCorp Vault"
print " 3) AWS Secrets Manager"
print ""
print -n "Select backend (1-3): "
let backend_choice = (input)
let backend = match $backend_choice {
"1" => "sops"
"2" => "vault"
"3" => "aws-secrets"
_ => "sops"
}
print ""
print -n "Enter secret location/path: "
let secret_path = (input)
if ($secret_path | is-empty) {
print "Error: Secret path cannot be empty" | error
return {}
}
{
backend: $backend
secret_path: $secret_path
timestamp: (date now | format date "%Y-%m-%dT%H:%M:%SZ")
}
}

View file

@ -3,9 +3,10 @@
use ../config/accessor.nu *
# Check if KMS plugin is available
# Check if KMS plugin is available (registered with Nushell)
def is-plugin-available [] {
(which kms | length) > 0
let installed = (version | get installed_plugins)
$installed | str contains "kms"
}
# Check if KMS plugin is enabled in config
@ -62,7 +63,7 @@ export def plugin-kms-encrypt [
}
# HTTP fallback - call KMS service directly
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/api/encrypt"
@ -119,7 +120,7 @@ export def plugin-kms-decrypt [
}
# HTTP fallback - call KMS service directly
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/api/decrypt"
@ -171,7 +172,7 @@ export def plugin-kms-generate-key [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/api/keys/generate"
@ -216,7 +217,7 @@ export def plugin-kms-status [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/health"
@ -253,7 +254,7 @@ export def plugin-kms-backends [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/api/backends"
@ -299,7 +300,7 @@ export def plugin-kms-rotate-key [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/api/keys/rotate"
@ -342,7 +343,7 @@ export def plugin-kms-list-keys [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_kms not available - using HTTP fallback for key management"
let kms_url = (get-kms-url)
let url = $"($kms_url)/api/keys?backend=($backend_name)"

View file

@ -3,9 +3,10 @@
use ../config/accessor.nu *
# Check if orchestrator plugin is available
# Check if orchestrator plugin is available (registered with Nushell)
def is-plugin-available [] {
(which orch | length) > 0
let installed = (version | get installed_plugins)
$installed | str contains "orchestrator"
}
# Check if orchestrator plugin is enabled in config
@ -15,7 +16,11 @@ def is-plugin-enabled [] {
# Get orchestrator base URL
def get-orchestrator-url [] {
config-get "platform.orchestrator.url" "http://localhost:8080"
if ($env.PROVISIONING_ORCHESTRATOR_URL? | is-not-empty) {
$env.PROVISIONING_ORCHESTRATOR_URL
} else {
config-get "platform.orchestrator.url" "http://localhost:9011"
}
}
# Get orchestrator data directory
@ -68,7 +73,7 @@ export def plugin-orch-status [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_orchestrator not available - using HTTP fallback for orchestration"
let url = $"(get-orchestrator-url)/health"
@ -150,7 +155,7 @@ export def plugin-orch-tasks [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_orchestrator not available - using HTTP fallback for orchestration"
let orch_url = get-orchestrator-url
let url = if ($status | is-empty) {
@ -212,7 +217,7 @@ export def plugin-orch-task [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_orchestrator not available - using HTTP fallback for orchestration"
let orch_url = get-orchestrator-url
let url = $"($orch_url)/tasks/($task_id)"
@ -248,7 +253,7 @@ export def plugin-orch-validate [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_orchestrator not available - using HTTP fallback for orchestration"
let orch_url = get-orchestrator-url
let url = $"($orch_url)/validate"
@ -329,7 +334,7 @@ export def plugin-orch-stats [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_orchestrator not available - using HTTP fallback for orchestration"
let orch_url = get-orchestrator-url
let url = $"($orch_url)/stats"

View file

@ -3,9 +3,10 @@
use ../config/accessor.nu *
# Check if SecretumVault plugin is available
# Check if SecretumVault plugin is available (registered with Nushell)
def is-plugin-available [] {
(which secretumvault | length) > 0
let installed = (version | get installed_plugins)
$installed | str contains "secretumvault"
}
# Check if SecretumVault plugin is enabled in config
@ -77,7 +78,7 @@ export def plugin-secretumvault-encrypt [
}
# HTTP fallback - call SecretumVault service directly
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_secretumvault not available - using HTTP fallback for secrets"
let sv_url = (get-secretumvault-url)
let sv_token = (get-secretumvault-token)
@ -142,7 +143,7 @@ export def plugin-secretumvault-decrypt [
}
# HTTP fallback - call SecretumVault service directly
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_secretumvault not available - using HTTP fallback for secrets"
let sv_url = (get-secretumvault-url)
let sv_token = (get-secretumvault-token)
@ -215,7 +216,7 @@ export def plugin-secretumvault-generate-key [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_secretumvault not available - using HTTP fallback for secrets"
let sv_url = (get-secretumvault-url)
let sv_token = (get-secretumvault-token)
@ -266,7 +267,7 @@ export def plugin-secretumvault-health [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_secretumvault not available - using HTTP fallback for secrets"
let sv_url = (get-secretumvault-url)
let url = $"($sv_url)/v1/sys/health"
@ -304,7 +305,7 @@ export def plugin-secretumvault-version [] {
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_secretumvault not available - using HTTP fallback for secrets"
let sv_url = (get-secretumvault-url)
let url = $"($sv_url)/v1/sys/health"
@ -348,7 +349,7 @@ export def plugin-secretumvault-rotate-key [
}
# HTTP fallback
print "⚠️ Using HTTP fallback (plugin not available)"
print "⚠️ nu_plugin_secretumvault not available - using HTTP fallback for secrets"
let sv_url = (get-secretumvault-url)
let sv_token = (get-secretumvault-token)

View file

@ -1,5 +1,6 @@
use utils *
use config/accessor.nu *
use ./utils/nickel_processor.nu [ncl-eval]
export def clip_copy [
msg: string
@ -90,11 +91,18 @@ export def process_decl_file [
] {
# Use external Nickel CLI (nickel export)
if (get-use-nickel) {
let result = (^nickel export $decl_file --format $format | complete)
if $result.exit_code == 0 {
$result.stdout
# Note: format parameter is only used if it's "json"; otherwise raw nickel export is needed
if $format == "json" {
let result = (ncl-eval $decl_file [])
$result | to json
} else {
error make { msg: $result.stderr }
# For non-JSON formats, use raw nickel command
let result = (do { ^nickel export $decl_file --format $format } | complete)
if $result.exit_code == 0 {
$result.stdout
} else {
error make { msg: $result.stderr }
}
}
} else {
error make { msg: "Nickel CLI not available" }

View file

@ -20,7 +20,7 @@ export def detect-project [
}
}
let mut args = [
mut args = [
"detect"
$project_path
"--format" $format
@ -68,7 +68,7 @@ export def complete-project [
}
}
let mut args = [
mut args = [
"complete"
$project_path
"--format" $format

View file

@ -5,14 +5,25 @@ use registry.nu *
use interface.nu *
use ../utils/logging.nu *
# Load provider dynamically with validation
# Load provider dynamically with validation (cached)
export def load-provider [name: string] {
# Silent loading - only log errors, not info/success
# Provider loading happens multiple times due to wrapper scripts, logging creates noise
# Check cache first - provider loading happens multiple times due to wrapper scripts
let cache_key = $"PROVIDER_CACHE_($name)"
if ($cache_key in ($env | columns)) {
return ($env | get $cache_key)
}
# Silent loading - only log debug, not errors for repeated loads
if ($env.PROVISIONING_DEBUG? | default false) {
log-debug $"Loading provider: ($name)" "provider-loader"
}
# Check if provider is available
if not (is-provider-available $name) {
log-error $"Provider ($name) not found or not available" "provider-loader"
if ($env.PROVISIONING_DEBUG? | default false) {
log-debug $"Provider ($name) not found or not available" "provider-loader"
}
load-env { $cache_key: {} }
return {}
}
@ -27,17 +38,33 @@ export def load-provider [name: string] {
}
if not ($provider_instance | is-empty) {
# Validate interface compliance
let validation = (validate-provider-interface $name $provider_instance)
# IMPORTANT: Skip subprocess-based validation for extension providers.
# Child nu processes don't inherit NICKEL_IMPORT_PATH or the provisioning env,
# so validate-provider-interface always reports functions missing even when valid.
# (Same documented fix as registry.nu:132-146 and load-extension-provider above)
# Core providers are loaded from known paths where subprocess context is reliable.
let skip_validation = ($provider_entry.type == "extension")
let validation = if $skip_validation {
{ valid: true, missing_functions: [] }
} else {
validate-provider-interface $name $provider_instance
}
if $validation.valid {
load-env { $cache_key: $provider_instance }
$provider_instance
} else {
log-error $"Provider ($name) failed interface validation" "provider-loader"
log-error $"Missing functions: ($validation.missing_functions | str join ', ')" "provider-loader"
if ($env.PROVISIONING_DEBUG? | default false) {
log-error $"Provider ($name) failed interface validation" "provider-loader"
log-error $"Missing functions: ($validation.missing_functions | str join ', ')" "provider-loader"
}
load-env { $cache_key: {} }
{}
}
} else {
log-error $"Failed to load provider module for ($name)" "provider-loader"
if ($env.PROVISIONING_DEBUG? | default false) {
log-error $"Failed to load provider module for ($name)" "provider-loader"
}
load-env { $cache_key: {} }
{}
}
}
@ -60,26 +87,25 @@ def load-core-provider [provider_entry: record] {
# Load extension provider
def load-extension-provider [provider_entry: record] {
# For extension providers, use the adapter pattern
# IMPORTANT: Do NOT spawn a child nu process to validate the provider.
# Child processes don't inherit NICKEL_IMPORT_PATH or the provisioning env,
# causing all providers to fail validation even though they are valid.
# (Same reason registry.nu skips subprocess validation — see registry.nu:132-146)
# Just verify the file exists and create the instance directly.
let module_path = $provider_entry.entry_point
# Test that the provider file exists and has the required functions
let test_cmd = $"nu -c \"use ($module_path) *; get-provider-metadata | to json\""
let test_result_check = (do { nu -c $test_cmd | complete })
if not ($module_path | path exists) {
log-error $"Provider module not found: ($module_path)" "provider-loader"
return {}
}
if ($test_result_check.exit_code != 0) {
log-error $"Provider validation failed for ($provider_entry.name)" "provider-loader"
{}
} else {
# Create provider instance record
{
name: $provider_entry.name
type: "extension"
loaded: true
entry_point: $module_path
load_time: (date now)
metadata: ($test_result_check.stdout | from json)
}
{
name: $provider_entry.name
type: "extension"
loaded: true
entry_point: $module_path
load_time: (date now)
metadata: {}
}
}
@ -151,7 +177,7 @@ let args = \(open ($args_file)\)
$script_content | save --force $wrapper_script
# Execute the wrapper script
let result = (do { nu $wrapper_script } | complete)
let result = (do --ignore-errors { nu $wrapper_script } | complete)
# Clean up temp files
if ($args_file | path exists) { rm -f $args_file }
@ -159,24 +185,17 @@ let args = \(open ($args_file)\)
# Return result if successful, null otherwise
if $result.exit_code == 0 {
# Try to parse as structured data (JSON, NUON, etc), fallback to string
# Parse output: always try JSON first (handles strings, bools, records, lists)
# The wrapper script serializes all return values with | to json, so bare JSON
# strings like "91.98.28.202" must go through from json to strip the quotes.
let output = ($result.stdout | str trim)
if ($output | is-empty) {
null
} else if $output == "true" {
true
} else if $output == "false" {
false
} else if ($output | str starts-with "{") or ($output | str starts-with "[") {
# Try JSON parse - use error handling for Nushell 0.107
let parsed = (do -i { $output | from json })
if ($parsed | is-empty) {
$output
} else {
$parsed
}
} else {
$output
let parsed = (do -i { $output | from json })
let value = if ($parsed | is-empty) { $output } else { $parsed }
log-debug $"($provider_name)::($function_name) → ($value)" "provider-loader"
$value
}
} else {
log-error $"Provider function call failed: ($result.stderr)" "provider-loader"

View file

@ -63,7 +63,7 @@ def discover-providers-only [] {
mut registry = {}
# Get provisioning system path from config or environment
let base_path = (config-get "provisioning.path" ($env.PROVISIONING? | default "/Users/Akasha/project-provisioning/provisioning"))
let base_path = (config-get "provisioning.path" ($env.PROVISIONING? | default ($env.HOME | path join "project-provisioning/provisioning")))
# PRIORITY 1: Workspace .providers (if in workspace context)
# Look for .providers in workspace root or parent directories
@ -129,31 +129,33 @@ def discover-providers-in-directory [base_path: string, provider_type: string] {
if ($provider_file | path exists) {
let provider_name = ($dir | path basename)
# COMMENTED OUT: Metadata verification was causing silent failures
# The nu -c subprocess doesn't have proper NICKEL_IMPORT_PATH configured
# This caused all providers to be skipped even though they are valid
# Solution: Just mark all providers with provider.nu as available
# Actual metadata loading happens when the provider is used
# Check if provider has metadata function (just test it's valid)
# We don't parse the metadata here, just verify the provider loads
# Suppress error output by redirecting to /dev/null
let has_metadata = (do {
^nu -c $"use ($provider_file) *; get-provider-metadata | ignore" o> /dev/null e> /dev/null
} | complete | get exit_code) == 0
# let has_metadata = (do {
# ^nu -c $"use ($provider_file) *; get-provider-metadata | ignore" o> /dev/null e> /dev/null
# } | complete | get exit_code) == 0
if $has_metadata {
let provider_info = {
name: $provider_name
type: $provider_type
path: $dir
entry_point: $provider_file
available: true
loaded: false
last_discovered: (date now)
}
$providers = ($providers | insert $provider_name $provider_info)
log-debug $" 📦 Found ($provider_type) provider: ($provider_name)" "provider-discovery"
} else {
# Silently skip invalid providers instead of warning
# This can happen with providers that have bugs - they'll be marked as unavailable
log-debug $" ⊘ Skipping invalid provider: ($provider_name)" "provider-discovery"
# if $has_metadata { ... } else { ... }
# INSTEAD: Simply register any provider.nu file as available
let provider_info = {
name: $provider_name
type: $provider_type
path: $dir
entry_point: $provider_file
available: true
loaded: false
last_discovered: (date now)
}
$providers = ($providers | insert $provider_name $provider_info)
log-debug $" 📦 Found ($provider_type) provider: ($provider_name)" "provider-discovery"
}
}

View file

@ -101,7 +101,7 @@ export def combine [result1: record, result2: record] {
# Combine list of Results (stops on first error)
# Type: list -> record
export def combine-all [results: list] {
let mut accumulated = (ok [])
mut accumulated = (ok [])
for result in $results {
if (is-err $accumulated) {
@ -133,11 +133,11 @@ export def try-wrap [fn: closure] {
# Match on Result (like Rust's match)
# Type: record, closure, closure -> any
export def match-result [result: record, on-ok: closure, on-err: closure] {
export def match-result [result: record, on_ok: closure, on_err: closure] {
if (is-ok $result) {
do $on-ok $result.ok
do $on_ok $result.ok
} else {
do $on-err $result.err
do $on_err $result.err
}
}

View file

@ -49,32 +49,20 @@ def http-health-check [
config: record
] {
let timeout = $config.timeout? | default 5
let expected_status = ($config.expected_status? | default 200)
let timeout_dur = ($"($timeout)sec" | into duration)
let http_result = (do {
http get --max-time ($timeout | into string + "s") $config.endpoint
} | complete)
let response = (try {
http head --allow-errors --full --max-time $timeout_dur $config.endpoint
} catch {
return { healthy: false, message: "HTTP health check failed - endpoint unreachable" }
})
if $http_result.exit_code == 0 {
# For simple health endpoints that return strings
{ healthy: true, message: "HTTP health check passed" }
let status = $response.status
if $status == $expected_status {
{ healthy: true, message: $"HTTP status ($status) matches expected" }
} else {
# Try with curl for more control
let curl_result = (do {
curl -s -o /dev/null -w "%{http_code}" -m $timeout $config.endpoint
} | complete)
if $curl_result.exit_code == 0 {
let status_code = $curl_result.stdout
let expected = ($config.expected_status | into string)
if $status_code == $expected {
{ healthy: true, message: $"HTTP status [$status_code] matches expected" }
} else {
{ healthy: false, message: $"HTTP status [$status_code] != expected [$expected]" }
}
} else {
{ healthy: false, message: "HTTP health check failed - endpoint unreachable" }
}
{ healthy: false, message: $"HTTP status ($status) != expected ($expected_status)" }
}
}
@ -152,8 +140,7 @@ export def retry-health-check [
if $attempt < ($max_retries + 1) {
print $"Health check failed (attempt ($attempt)/($max_retries)), retrying in ($interval)s..."
let interval_str = $interval | into string
sleep ($"($interval_str)sec" | into duration)
sleep ($"($interval)sec" | into duration)
}
}
@ -198,8 +185,7 @@ export def wait-for-service [
}
print $"Waiting for ($service)... (($check_result.message))"
let sleep_duration = ($interval | into string) + "sec"
sleep ($sleep_duration | into duration)
sleep ($"($interval)sec" | into duration)
wait_loop $service $config $start $timeout_ns $interval
}
@ -261,7 +247,6 @@ export def monitor-service-health [
print $"⚠️ ALERT: Service ($service_name) is unhealthy!"
}
let sleep_duration = ($interval | into string) + "sec"
sleep ($sleep_duration | into duration)
sleep ($"($interval)sec" | into duration)
}
}

View file

@ -21,8 +21,8 @@ export def install_config [
let reset = ($ops | str contains "reset")
let use_context = if ($ops | str contains "context") or $context { true } else { false }
let provisioning_config_path = $nu.default-config-dir | path dirname | path join $provisioning_cfg_name | path join "nushell"
let provisioning_root = if ((get-base-path) | is-not-empty) {
(get-base-path)
let provisioning_root = if ((get-config-base-path) | is-not-empty) {
(get-config-base-path)
} else {
let base_path = if ($env.PROCESS_PATH | str contains "provisioning") {
$env.PROCESS_PATH

View file

@ -8,6 +8,7 @@ use ../utils/logging.nu *
# Re-export existing utilities and config helpers
export use utils.nu *
export use config.nu *
# Note: wizard.nu is imported by callers directly - avoid circular import with mod.nu
# ============================================================================
# CONFIGURATION PATH HELPERS
@ -34,7 +35,7 @@ export def get-config-base-path [] {
# Get provisioning installation path
export def get-install-path [] {
config-get "setup.install_path" (get-base-path)
config-get "setup.install_path" (get-config-base-path)
}
# Get global workspaces directory

View file

@ -6,6 +6,7 @@ use ./mod.nu *
use ./detection.nu *
use ./validation.nu *
use ./wizard.nu *
use ../utils/nickel_processor.nu [ncl-eval-soft]
# ============================================================================
# SYSTEM CONFIGURATION CREATION
@ -253,10 +254,10 @@ export def setup-cedar-policies [
# Get Nickel schema path for config type
def get-nickel-schema-path [config_type: string] {
match $config_type {
"system" => "provisioning/schemas/platform/schemas/system.ncl"
"deployment" => "provisioning/schemas/platform/schemas/deployment.ncl"
"user_preferences" => "provisioning/schemas/platform/schemas/user_preferences.ncl"
"provider" => "provisioning/schemas/platform/schemas/provider.ncl"
"system" => "provisioning/schemas/platform/system.ncl"
"deployment" => "provisioning/schemas/platform/deployment.ncl"
"user_preferences" => "provisioning/schemas/platform/user_preferences.ncl"
"provider" => "provisioning/schemas/platform/provider.ncl"
_ => ""
}
}
@ -279,7 +280,7 @@ export def create-system-config-nickel [
# Profile: ($profile)
let helpers = import \"../../schemas/platform/common/helpers.ncl\" in
let system_schema = import \"../../schemas/platform/schemas/system.ncl\" in
let system_schema = import \"../../schemas/platform/system.ncl\" in
let defaults = import \"../../schemas/platform/defaults/system-defaults.ncl\" in
# Compose: defaults + platform-specific values
@ -324,7 +325,7 @@ export def create-platform-config-nickel [
# Deployment Mode: ($deployment_mode)
let helpers = import \"../../schemas/platform/common/helpers.ncl\" in
let deployment_schema = import \"../../schemas/platform/schemas/deployment.ncl\" in
let deployment_schema = import \"../../schemas/platform/deployment.ncl\" in
let defaults = import \"../../schemas/platform/defaults/deployment-defaults.ncl\" in
# Profile-specific overlay
@ -370,7 +371,7 @@ export def create-user-preferences-nickel [
# Profile: ($profile)
let helpers = import \"../../schemas/platform/common/helpers.ncl\" in
let prefs_schema = import \"../../schemas/platform/schemas/user_preferences.ncl\" in
let prefs_schema = import \"../../schemas/platform/user_preferences.ncl\" in
let defaults = import \"../../schemas/platform/defaults/user_preferences-defaults.ncl\" in
# Profile-specific overlay (production has stricter defaults)
@ -410,7 +411,7 @@ export def create-provider-config-nickel [
$"# UpCloud Provider Configuration (Nickel)
# Generated: (get-timestamp-iso8601)
let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
let provider_schema = import \"../../schemas/platform/provider.ncl\" in
{
api_url = \"https://api.upcloud.com/1.3\",
@ -425,7 +426,7 @@ let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
$"# AWS Provider Configuration (Nickel)
# Generated: (get-timestamp-iso8601)
let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
let provider_schema = import \"../../schemas/platform/provider.ncl\" in
{
region = \"us-east-1\",
@ -439,7 +440,7 @@ let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
$"# Hetzner Provider Configuration (Nickel)
# Generated: (get-timestamp-iso8601)
let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
let provider_schema = import \"../../schemas/platform/provider.ncl\" in
{
api_url = \"https://api.hetzner.cloud/v1\",
@ -453,7 +454,7 @@ let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
$"# Local Provider Configuration (Nickel)
# Generated: (get-timestamp-iso8601)
let provider_schema = import \"../../schemas/platform/schemas/provider.ncl\" in
let provider_schema = import \"../../schemas/platform/provider.ncl\" in
{
base_path = \"/tmp/provisioning-local\",
@ -538,7 +539,7 @@ export def export-nickel-to-toml [
}
# Run nickel export
let export_result = (do { nickel export --format toml $ncl_path | save -f $toml_path } | complete)
let export_result = (do { ^nickel export --format toml $ncl_path | save -f $toml_path } | complete)
if ($export_result.exit_code == 0) {
return true

View file

@ -1,5 +1,6 @@
#use ../lib_provisioning/defs/lists.nu providers_list
use ../config/accessor.nu *
use ../utils/nickel_processor.nu [ncl-eval-soft]
export def setup_config_path [
provisioning_cfg_name: string = "provisioning"
@ -11,7 +12,7 @@ export def tools_install [
run_args?: string
] {
print $"(_ansi cyan)((get-provisioning-name))(_ansi reset) (_ansi yellow_bold)tools(_ansi reset) check:\n"
let bin_install = ((get-base-path) | path join "core" | path join "bin" | path join "tools-install")
let bin_install = ((get-config-base-path) | path join "core" | path join "bin" | path join "tools-install")
if not ($bin_install | path exists) {
print $"🛑 Error running (_ansi yellow)tools_install(_ansi reset) not found (_ansi red_bold)($bin_install | path basename)(_ansi reset)"
if (is-debug-enabled) { print $"($bin_install)" }
@ -58,7 +59,7 @@ export def create_versions_file [
targetname: string = "versions"
] {
let target_name = if ($targetname | is-empty) { "versions" } else { $targetname }
let provisioning_base = ($env.PROVISIONING? | default (get-base-path))
let provisioning_base = ($env.PROVISIONING? | default (get-config-base-path))
let versions_ncl = ($provisioning_base | path join "core" | path join "versions.ncl")
let versions_target = ($provisioning_base | path join "core" | path join $target_name)
let providers_path = ($provisioning_base | path join "extensions" | path join "providers")
@ -74,10 +75,9 @@ export def create_versions_file [
# ============================================================================
# CORE TOOLS
# ============================================================================
let nickel_result = (^nickel export $versions_ncl --format json | complete)
let json_data = (ncl-eval-soft $versions_ncl [] null)
if $nickel_result.exit_code == 0 {
let json_data = ($nickel_result.stdout | from json)
if $json_data != null {
let core_versions = ($json_data | get core_versions? | default [])
for item in $core_versions {
@ -126,10 +126,9 @@ export def create_versions_file [
let provider_version_file = ($provider_dir | path join "nickel" | path join "version.ncl")
if ($provider_version_file | path exists) {
let provider_result = (^nickel export $provider_version_file --format json | complete)
let provider_data = (ncl-eval-soft $provider_version_file [] null)
if $provider_result.exit_code == 0 {
let provider_data = ($provider_result.stdout | from json)
if $provider_data != null {
let prov_name = ($provider_data | get name?)
let prov_version_obj = ($provider_data | get version?)

View file

@ -11,6 +11,7 @@
use ./mod.nu *
use ./detection.nu *
use ./validation.nu *
use ../utils/path-utils.nu *
# ============================================================================
# INPUT HELPERS
@ -560,61 +561,46 @@ export def run-minimal-setup [] {
# Run TypeDialog form via bash wrapper and return parsed result
# This pattern avoids TTY/input issues in Nushell's execution stack
def run-typedialog-form [
wrapper_script: string
form_path: string
--backend: string = "tui"
] {
# Check if the wrapper script exists
if not ($wrapper_script | path exists) {
print-setup-warning "TypeDialog wrapper not found. Using fallback prompts."
# Guard 1: Check if plugin is available
if (which typedialog | is-empty) {
print-setup-error "TypeDialog plugin not available"
return {
success: false
error: "TypeDialog wrapper not available"
error: "TypeDialog plugin not available"
use_fallback: true
}
}
# Set backend environment variable
$env.TYPEDIALOG_BACKEND = $backend
# Run bash wrapper (handles TTY input properly)
let result = (do { bash $wrapper_script } | complete)
if $result.exit_code != 0 {
print-setup-error "TypeDialog wizard failed or was cancelled"
# Guard 2: Check if form file exists
if not ($form_path | path exists) {
print-setup-error $"Form not found: ($form_path)"
return {
success: false
error: $result.stderr
error: $"Form not found: ($form_path)"
use_fallback: true
}
}
# Read the generated JSON file
let json_output = ($wrapper_script | path dirname | path join "generated" | path join ($wrapper_script | path basename | str replace ".sh" "-result.json"))
# Main logic: Call the nu_plugin_typedialog plugin directly
# The plugin handles TTY properly via Nushell's native plugin protocol
let result = (typedialog form $form_path --backend $backend)
if not ($json_output | path exists) {
print-setup-warning "TypeDialog output not found. Using fallback."
if ($result | is-empty) {
# User cancelled the form
print-setup-warning "Setup wizard was cancelled"
return {
success: false
error: "Output file not found"
use_fallback: true
error: "Form cancelled by user"
use_fallback: false
}
}
# Parse JSON output (no try-catch)
let parse_result = (do { open $json_output | from json } | complete)
if $parse_result.exit_code != 0 {
return {
success: false
error: "Failed to parse TypeDialog output"
use_fallback: true
}
}
let values = ($parse_result.stdout)
{
success: true
values: $values
values: $result
use_fallback: false
}
}
@ -624,7 +610,7 @@ def run-typedialog-form [
# ============================================================================
# Run setup wizard using TypeDialog - modern TUI experience
# Uses bash wrapper to handle TTY input properly
# Uses plugin directly for proper TTY handling
export def run-setup-wizard-interactive [
--backend: string = "tui"
] {
@ -637,9 +623,9 @@ export def run-setup-wizard-interactive [
print "╚═══════════════════════════════════════════════════════════════╝"
print ""
# Run the TypeDialog-based wizard via bash wrapper
let wrapper_script = "provisioning/core/shlib/setup-wizard-tty.sh"
let form_result = (run-typedialog-form $wrapper_script --backend $backend)
# Get TypeDialog form path with absolute resolution
let form_path = (get-typedialog-form-path "setup-wizard.toml")
let form_result = (run-typedialog-form $form_path --backend $backend)
# If TypeDialog not available or failed, fall back to basic wizard
if (not $form_result.success or $form_result.use_fallback) {

View file

@ -2,6 +2,7 @@
use std
use ../config/accessor.nu *
use ../utils/interface.nu *
use ../utils/init.nu [get-provisioning-use-sops, get-workspace-path, get-provisioning-infra-path]
def find_file [
start_path: string
@ -34,8 +35,9 @@ export def run_cmd_sops [
let use_sops_value = (get-provisioning-use-sops | into string)
let res = if ($use_sops_value | str contains "age") {
if $env.SOPS_AGE_RECIPIENTS? != null {
# print $"SOPS_AGE_KEY_FILE=((get-sops-age-key-file)) ; sops ($str_cmd) --config ((find-sops-key)) --age ($env.SOPS_AGE_RECIPIENTS) ($source_path)"
(^bash -c SOPS_AGE_KEY_FILE=((get-sops-age-key-file)) ; sops $str_cmd --config (find-sops-key) --age $env.SOPS_AGE_RECIPIENTS $source_path | complete )
(with-env { SOPS_AGE_KEY_FILE: (get-sops-age-key-file) } {
do { ^sops $str_cmd --config (find-sops-key) --age $env.SOPS_AGE_RECIPIENTS $source_path } | complete
})
} else {
if $error_exit {
(throw-error $"🛑 Sops with age error" $"(_ansi red)no AGE_RECIPIENTS(_ansi reset) for (_ansi green)($source_path)(_ansi reset)"
@ -243,7 +245,7 @@ export def get_def_age [
current_path: string
] {
# Check if SOPS is configured for age encryption
let use_sops = (get-provisioning-use-sops | tostring)
let use_sops = (get-provisioning-use-sops | into string)
if not ($use_sops | str contains "age") {
return ""
}
@ -277,3 +279,16 @@ export def get_def_age [
}
($provisioning_kage | default "")
}
# Return the SOPS config file path — env-var fast path, then filesystem search.
export def find-sops-key [] {
let from_env = ($env.PROVISIONING_SOPS? | default "")
if ($from_env | is-not-empty) { return $from_env }
let search_path = ($env.CURRENT_KLOUD_PATH? | default ($env.PROVISIONING_WORKSPACE_PATH? | default $env.PWD))
get_def_sops $search_path
}
# Return the age private-key file path used for SOPS encryption/decryption.
export def get-sops-age-key-file [] {
$env.SOPS_AGE_KEY_FILE? | default ($env.PROVISIONING_KAGE? | default "")
}

View file

@ -1,6 +1,8 @@
# User Configuration Management Module
# Manages central user configuration file for workspace switching and preferences
use ../utils/nickel_processor.nu [ncl-eval-soft]
# Get path to user config file
export def get-user-config-path [] {
let user_config_dir = ([$env.HOME "Library" "Application Support" "provisioning"] | path join)
@ -371,13 +373,12 @@ export def get-workspace-default-infra [workspace_name: string] {
let ws_path = (get-workspace-path $workspace_name)
let ws_config_file = ([$ws_path "config" "provisioning.ncl"] | path join)
if ($ws_config_file | path exists) {
let result = (do -i {
let ws_config = (^nickel export $ws_config_file --format json | from json)
let current_infra = ($ws_config.workspace_config.workspace.current_infra? | default null)
$current_infra
})
let result = (ncl-eval-soft $ws_config_file [] null)
if ($result | is-not-empty) {
return $result
let current_infra = ($result.current_infra? | default null)
if ($current_infra | is-not-empty) {
return $current_infra
}
}
}

View file

@ -1,4 +1,6 @@
use ../config/accessor.nu *
use ./logging.nu *
use ./interface.nu *
export def cleanup [
wk_path: string
@ -6,7 +8,6 @@ export def cleanup [
if not (is-debug-enabled) and ($wk_path | path exists) {
rm --force --recursive $wk_path
} else {
#use utils/interface.nu _ansi
_print $"(_ansi default_dimmed)______________________(_ansi reset)"
_print $"(_ansi default_dimmed)Work files not removed"
_print $"(_ansi default_dimmed)wk_path:(_ansi reset) ($wk_path)"

View file

@ -0,0 +1,63 @@
# Module: Command Registry
# Purpose: Parse and query the commands registry Nickel file for command metadata
use ./nickel_processor.nu [ncl-eval]
# Parse commands registry Nickel file via JSON export
# Returns array of records with command metadata
def parse_registry [] {
let registry_file = (
if ($env.PROVISIONING? | is-not-empty) {
$env.PROVISIONING | path join "core" "nulib" "commands-registry.ncl"
} else {
"./provisioning/core/nulib/commands-registry.ncl"
}
)
if not ($registry_file | path exists) {
error make {msg: $"Registry file not found: ($registry_file)"}
}
(ncl-eval $registry_file []) | .commands
}
# Get help category for a command (for commands requiring args)
export def get_help_category_for_command [command: string] {
let registry = (parse_registry)
let entry = ($registry | where { |r|
($r.command == $command) or ($r.aliases | any { |a| $a == $command })
} | first)
if ($entry | is-not-empty) and ($entry.requires_args == true) {
$entry.help_category
} else {
""
}
}
# Check if command requires arguments
export def command_requires_args [command: string] {
let registry = (parse_registry)
let entry = ($registry | where { |r|
($r.command == $command) or ($r.aliases | any { |a| $a == $command })
} | first)
if ($entry | is-not-empty) {
$entry.requires_args == true
} else {
false
}
}
# Get all commands that require arguments with their help categories
export def get_commands_requiring_args [] {
let registry = (parse_registry)
$registry
| where { |r| $r.requires_args == true and ($r.help_category | is-not-empty) }
| each { |r|
{
command: $r.command
help_category: $r.help_category
}
}
}

View file

@ -1,8 +1,10 @@
# Module: Error Handling Utilities
# Purpose: Centralized error handling, error messages, and exception management.
# Dependencies: None (core utility)
# Dependencies: logging
use ../config/accessor.nu *
use ./logging.nu *
use ./interface.nu [_ansi]
export def throw-error [
error: string
@ -24,7 +26,7 @@ export def throw-error [
print $"DEBUG: Error code: ($code)"
}
if ($env.PROVISIONING_OUT | is-empty) {
if ($env.PROVISIONING_OUT? | default "" | is-empty) {
if $span == null and $context == null {
error make --unspanned { msg: ( $error + "\n" + $msg + $suggestion) }
} else if $span != null and (is-metadata-enabled) {
@ -62,22 +64,3 @@ export def safe-execute [
$result.stdout
}
}
export def try [
settings_data: record
defaults_data: record
] {
$settings_data.servers | each { |server|
_print ( $defaults_data.defaults | merge $server )
}
_print ($settings_data.servers | get hostname)
_print ($settings_data.servers | get 0).tasks
let zli_cfg = (open "resources/oci-reg/zli-cfg" | from json)
if $zli_cfg.sops? != null {
_print "Found"
} else {
_print "NOT Found"
}
let pos = 0
_print ($settings_data.servers | get $pos )
}

View file

@ -1,6 +1,8 @@
# Intelligent Hints and Next-Step Guidance System
# Provides contextual hints, documentation links, and next-step suggestions
use interface.nu [_ansi]
# Show next step suggestion after successful operation
export def show-next-step [
operation: string # Operation that just completed
@ -24,10 +26,9 @@ export def show-next-step [
let service_name = ($ctx | get name? | default "service")
print $"\n(_ansi green_bold)✓ Taskserv '($service_name)' installed successfully!(_ansi reset)\n"
print $"(_ansi cyan_bold)Next steps:(_ansi reset)"
print $" 1. (_ansi blue)Verify installation:(_ansi reset) provisioning taskserv validate ($service_name)"
print $" 2. (_ansi blue)Create cluster:(_ansi reset) provisioning cluster create <cluster-name>"
print $" (_ansi default_dimmed)Available clusters: buildkit, ci-cd, monitoring(_ansi reset)"
print $" 3. (_ansi blue)Install more services:(_ansi reset) provisioning taskserv create <service>"
print $" 1. (_ansi blue)Dry-run check:(_ansi reset) provisioning taskserv create ($service_name) --check"
print $" 2. (_ansi blue)Install more services:(_ansi reset) provisioning taskserv create <service>"
print $" 3. (_ansi blue)Create cluster:(_ansi reset) provisioning cluster create <cluster-name>"
print $"\n(_ansi yellow)💡 Quick guide:(_ansi reset) provisioning guide from-scratch"
print $"(_ansi yellow)💡 Documentation:(_ansi reset) provisioning help infrastructure\n"
}

View file

@ -5,14 +5,90 @@
use ../config/accessor.nu *
# Get the complete provisioning command arguments as a string
export def get-provisioning-args [] : nothing -> string {
$env.PROVISIONING_ARGS? | default ""
}
# Get the provisioning command name
export def get-provisioning-name [] : nothing -> string {
$env.PROVISIONING_NAME? | default "provisioning"
}
# Get the provisioning infrastructure path
export def get-provisioning-infra-path [] : nothing -> string {
$env.PROVISIONING_INFRA_PATH? | default ""
}
# Get the provisioning resources path
export def get-provisioning-resources [] : nothing -> string {
$env.PROVISIONING_RESOURCES? | default ""
}
# Get the provisioning URL
export def get-provisioning-url [] : nothing -> string {
$env.PROVISIONING_URL? | default "https://provisioning.systems"
}
# Get whether SOPS encryption is enabled
export def get-provisioning-use-sops [] : nothing -> string {
$env.PROVISIONING_USE_SOPS? | default ""
}
# Get the effective workspace
export def get-effective-workspace [] : nothing -> string {
$env.CURRENT_WORKSPACE? | default "default"
}
# Get workspace path (defaults to effective workspace if not provided)
export def get-workspace-path [workspace?: string] : nothing -> string {
let ws = if ($workspace | is-empty) {
(get-effective-workspace)
} else {
$workspace
}
let ws_base = ($env.PROVISIONING_WORKSPACES? | default "")
if ($ws_base | is-not-empty) {
$ws_base | path join $ws
} else {
""
}
}
# Detect infrastructure from PWD
export def detect-infra-from-pwd [] : nothing -> string {
""
}
# Get work format (Nickel is the default post-migration)
export def get-work-format [] : nothing -> string {
$env.PROVISIONING_WORK_FORMAT? | default "ncl"
}
export def show_titles [] {
if (detect_claude_code) { return false }
# Check if titles are disabled
if ($env.PROVISIONING_NO_TITLES? | default false) { return }
if ($env.PROVISIONING_OUT | is-not-empty) { return }
# Prevent double title display
if ($env.PROVISIONING_OUT? | is-not-empty) { return }
if ($env.PROVISIONING_TITLES_SHOWN? | default false) { return }
# Mark as shown to prevent duplicates
$env.PROVISIONING_TITLES_SHOWN = true
_print $"(_ansi blue_bold)(open -r ((get-provisioning-resources) | path join "ascii.txt"))(_ansi reset)"
# Find ascii.txt from PROVISIONING_RESOURCES or PROVISIONING directory
let ascii_file = (
if ($env.PROVISIONING_RESOURCES? | is-not-empty) {
($env.PROVISIONING_RESOURCES | path join "ascii.txt")
} else if ($env.PROVISIONING? | is-not-empty) {
($env.PROVISIONING | path join "resources" | path join "ascii.txt")
} else {
""
}
)
# Display if file exists
if ($ascii_file | is-not-empty) and ($ascii_file | path exists) {
print $"(ansi blue_bold)(open -r $ascii_file)(ansi reset)"
}
}
export def use_titles [ ] {
if ($env.PROVISIONING_NO_TITLES? | default false) { return false }

View file

@ -1,8 +1,48 @@
# Module: User Interface Utilities
# Purpose: Provides terminal UI utilities: output formatting, prompts, spinners, and status displays.
# Dependencies: error for error handling
# Dependencies: error for error handling, logging for debug utilities
use ../config/accessor.nu *
use logging.nu [is-debug-enabled]
# Check if no-terminal mode is enabled
export def get-provisioning-no-terminal [] {
# Check environment variable first (use -o for optional in Nushell 0.106+)
let env_no_terminal = ($env | get -o PROVISIONING_NO_TERMINAL | default "false")
if ($env_no_terminal == "true") or ($env_no_terminal == "1") {
return true
}
# Check config setting
config-get "debug.no_terminal" false
}
# Get output format (json, yaml, table, etc.)
export def get-provisioning-out [] {
# Check environment variable first
let env_out = ($env | get -o PROVISIONING_OUT | default "")
if ($env_out | is-not-empty) {
return $env_out
}
# Check config setting
config-get "output.format" ""
}
# Set no-terminal mode
export def set-provisioning-no-terminal [value: bool] {
$env.PROVISIONING_NO_TERMINAL = $value
}
# Set output format
export def set-provisioning-out [value: string] {
$env.PROVISIONING_OUT = $value
}
# Get notification icon path
export def get-notify-icon [] : nothing -> string {
$env.PROVISIONING_NOTIFY_ICON? | default ""
}
export def _ansi [
arg?: string
@ -119,7 +159,7 @@ export def _print [
export def end_run [
context: string
] {
if ($env.PROVISIONING_OUT | is-not-empty) { return }
if ($env.PROVISIONING_OUT? | default "" | is-not-empty) { return }
if ($env.PROVISIONING_NO_TITLES? | default false) { return }
if (detect_claude_code) { return }
if (is-debug-enabled) {
@ -146,7 +186,10 @@ export def show_clip_to [
] {
if $show { _print $msg }
if (is-terminal --stdout) {
clip_copy $msg $show
if ((version).installed_plugins | str contains "clipboard") {
$msg | clipboard copy
print $"(ansi default_dimmed)copied into clipboard now (ansi reset)"
}
}
}
@ -186,10 +229,18 @@ export def desktop_run_notify [
(if $result.status { "✅ done " } else { $"🛑 fail ($result.error)" })
} else { "" }
let time_body = $"($body) ($msg) finished in ($total) "
( notify_msg $title $body $icon_path $time_body $timeout $task )
if ((version).installed_plugins | str contains "desktop_notifications") {
notify -s $title -t $time_body --timeout $time_out -i $icon_path
} else {
_print $"(_ansi blue)($title)(_ansi reset)\n(_ansi blue_bold)($time_body)(_ansi reset)"
}
return $result
} else {
( notify_msg $title $body $icon_path "" $timeout $task )
if ((version).installed_plugins | str contains "desktop_notifications") {
notify -s $title -t $body --timeout $time_out -i $icon_path
} else {
_print $"(_ansi blue)($title)(_ansi reset)\n(_ansi blue_bold)($body)(_ansi reset)"
}
true
}
}

View file

@ -4,7 +4,32 @@ use ../config/accessor.nu *
# Check if debug mode is enabled
export def is-debug-enabled [] {
(config-get "debug.enabled" false)
let raw = ($env.PROVISIONING_DEBUG? | default false)
let env_debug = if ($raw | describe) == "string" { $raw == "true" or $raw == "1" } else { $raw | into bool }
let config_debug = (config-get "debug.enabled" false)
$env_debug or $config_debug
}
# Check if debug-check mode is enabled (local task/service simulation)
export def is-debug-check-enabled [] {
$env.PROVISIONING_DEBUG_CHECK? | default false | into bool
}
# Check if metadata mode is enabled (for detailed error spans/metadata)
export def is-metadata-enabled [] {
let env_metadata = ($env.PROVISIONING_METADATA? | default false)
let config_metadata = (config-get "debug.metadata" false)
$env_metadata or $config_metadata
}
# Enable debug mode
export def set-debug-enabled [value: bool] {
$env.PROVISIONING_DEBUG = $value
}
# Enable metadata mode
export def set-metadata-enabled [value: bool] {
$env.PROVISIONING_METADATA = $value
}
export def log-info [

View file

@ -8,6 +8,7 @@ export use init.nu *
export use generate.nu *
export use undefined.nu *
export use logging.nu *
export use qr.nu *
export use ssh.nu *

View file

@ -0,0 +1,95 @@
# Nickel file processor — plugin-backed evaluation with cache, with direct fallback.
# Raw export via ^nickel binary (no cache). Used internally as fallback.
export def process_nickel_export_raw [
src_file: string
out_format: string
]: nothing -> string {
let prov_root = ($env.PROVISIONING? | default "/usr/local/provisioning")
^nickel export $src_file --format $out_format --import-path $prov_root
}
# Build the canonical import path list — used by callers that want explicit control.
#
# NOTE: After dropping import_paths from the cache key (plugin + daemon),
# the list passed here only affects cold-path nickel export invocations, NOT
# cache lookups. So mismatches between daemon and caller no longer cause misses.
export def default-ncl-paths [workspace: string = ""]: nothing -> list {
let ws = if ($workspace | is-not-empty) { $workspace } else { $env.PWD }
# Workspace-scoped paths (ontoref convention)
mut paths = [
($ws | path join ".ontology")
($ws | path join "adrs")
($ws | path join ".ontoref" | path join "ontology" | path join "schemas")
($ws | path join ".ontoref" | path join "adrs")
($ws | path join ".onref")
$ws
]
# $PROVISIONING
if ($env.PROVISIONING? | is-not-empty) { $paths = ($paths | append $env.PROVISIONING) }
# $NICKEL_IMPORT_PATH — colon-separated
if ($env.NICKEL_IMPORT_PATH? | is-not-empty) {
for entry in ($env.NICKEL_IMPORT_PATH | split row ":" | where { $in | is-not-empty }) {
$paths = ($paths | append $entry)
}
}
# $ONTOREF_ROOT — auto-discover or default macOS path
let ontoref_root = if ($env.ONTOREF_ROOT? | is-not-empty) {
$env.ONTOREF_ROOT
} else {
let home = ($env.HOME? | default "~" | path expand)
let mac_path = ($home | path join "Library" | path join "Application Support" | path join "ontoref")
let linux_path = ($home | path join ".local" | path join "share" | path join "ontoref")
if ($mac_path | path exists) {
$mac_path
} else {
if ($linux_path | path exists) { $linux_path } else { "" }
}
}
if ($ontoref_root | is-not-empty) {
$paths = ($paths | append [
($ontoref_root | path join "ontology")
($ontoref_root | path join "ontology" | path join "schemas")
($ontoref_root | path join "reflection")
($ontoref_root | path join "reflection" | path join "schemas")
($ontoref_root | path join "adrs")
$ontoref_root
] | flatten)
}
# De-duplicate preserving order (same as daemon)
$paths | reduce --fold [] {|it, acc|
if ($it in $acc) { $acc } else { $acc | append $it }
}
}
# Evaluate a Nickel file via the plugin (cached). Error propagates on failure.
#
# Equivalent to: ^nickel export --format json --import-path ... $path | from json
# but uses the nu_plugin_nickel cache, returning a Nu record/list directly.
export def ncl-eval [
path: string
import_paths: list = []
]: nothing -> any {
nickel-eval $path --import-path $import_paths
}
# Evaluate a Nickel file via the plugin (cached). Returns `fallback` on any error.
#
# Use for best-effort reads where failure is acceptable (e.g. optional NCL files).
# try/catch is valid for Nu plugin commands in Nu 0.111.0+.
export def ncl-eval-soft [
path: string
import_paths: list = []
fallback: any = null
]: nothing -> any {
try {
nickel-eval $path --import-path $import_paths
} catch {
$fallback
}
}

View file

@ -0,0 +1,61 @@
# Module: Path Resolution Utilities
# Purpose: Provides helpers for resolving provisioning project root and constructing absolute paths
# Used by: TypeDialog integration, setup wizard, auth forms
# Resolve provisioning project root with multiple fallback strategies
# Returns: The root directory that CONTAINS the provisioning folder
export def resolve-provisioning-root [] {
if "PROVISIONING_ROOT" in $env {
return $env.PROVISIONING_ROOT
}
if "PROVISIONING" in $env {
# PROVISIONING env var points to the provisioning folder itself
# We need its parent directory
let provisioning_dir = $env.PROVISIONING
let parent = ($provisioning_dir | path dirname)
# Verify the parent contains the provisioning folder
if ($parent | path join "provisioning" | path exists) {
return $parent
} else {
# PROVISIONING is already the project root
return $provisioning_dir
}
}
# Find project root by walking up from current directory
# We're looking for the directory that CONTAINS the provisioning folder
mut search_dir = (pwd)
mut found_root = ""
# Try 10 levels up maximum to find project root
for i in (0..9) {
let provisioning_path = ($search_dir | path join "provisioning")
if ($provisioning_path | path exists) and (($provisioning_path | path type) == "dir") {
# Found the root - it's the parent of the provisioning dir
$found_root = $search_dir
break
}
let parent = ($search_dir | path dirname)
if $parent == $search_dir {
break # Reached filesystem root
}
$search_dir = $parent
}
if ($found_root | is-empty) {
# Last resort: return current directory
pwd
} else {
$found_root
}
}
# Get TypeDialog form path with absolute resolution
export def get-typedialog-form-path [form_name: string] {
let provisioning_root = (resolve-provisioning-root)
$provisioning_root | path join "provisioning" ".typedialog" "core" "forms" $form_name
}

View file

@ -1,5 +1,22 @@
use ../config/accessor.nu *
# Display QR code for URL using qr_maker plugin or fallback
def show_qr [url: string]: nothing -> nothing {
let has_qr_plugin = ((version).installed_plugins | str contains "qr_maker")
if $has_qr_plugin {
print ($url | to qr)
} else {
let qr_path = ((get-provisioning-resources) | path join "qrs" | path join ($url | path basename))
if ($qr_path | path exists) {
print (open -r $qr_path)
} else {
print $"(ansi blue_reverse)($url)(ansi reset)"
print $"(ansi purple)($url)(ansi reset)"
}
}
}
export def "make_qr" [
url?: string
] {

View file

@ -0,0 +1,84 @@
# Script compression utilities for secure transmission
# Compresses template path, variables, and script as a complete auditable unit
# Compress complete workflow data (template + vars + script)
export def compress-workflow [template_path: string, template_vars: record, script: string]: nothing -> record {
# Create temporary directory
let timestamp_hash = ((date now | format date "%Y%m%d_%H%M%S") | hash sha256 | str substring 0..8)
let temp_dir = $"/tmp/workflow_($timestamp_hash)"
^mkdir -p $temp_dir
# 1. Compress template_vars (JSON)
let vars_file = ($temp_dir + "/vars.json")
let vars_json = ($template_vars | to json)
$vars_json | save -f $vars_file
let vars_original_size = ($vars_json | str length)
# 2. Compress script
let script_file = ($temp_dir + "/script.sh")
$script | save -f $script_file
let script_original_size = ($script | str length)
# 3. Create manifest with template_path
let manifest_file = ($temp_dir + "/manifest.json")
{
template_path: $template_path
timestamp: ((date now) | format date "%Y-%m-%d %H:%M:%S UTC")
} | to json | save -f $manifest_file
# 4. Combine all into single archive
let total_original = ($vars_original_size + $script_original_size)
let archive_file = ($temp_dir + "/workflow.tar.gz")
^tar -czf $archive_file -C $temp_dir manifest.json vars.json script.sh
# 5. Encode to base64
let tar_content = (open -r $archive_file)
let compressed_data = ($tar_content | ^base64)
# Get compressed size using base64 encoded output (approximation)
let compressed_size = ($compressed_data | str length)
# Calculate ratio
let compression_ratio = ($compressed_size / $total_original)
# Cleanup
^rm -rf $temp_dir
{
template_path: $template_path
script_compressed: $compressed_data
script_encoding: "tar+gzip+base64"
original_size: $total_original
compressed_size: $compressed_size
compression_ratio: $compression_ratio
}
}
# Decompress workflow (for verification/testing)
export def decompress-workflow [script_compressed: string]: nothing -> record {
let timestamp_hash = ((date now | format date "%Y%m%d_%H%M%S") | hash sha256 | str substring 0..8)
let temp_dir = $"/tmp/workflow_decompress_($timestamp_hash)"
^mkdir -p $temp_dir
# Decode from base64
let decoded = (echo $script_compressed | ^base64 -d)
# Extract tar.gz
echo $decoded | ^tar -xzf - -C $temp_dir
# Read files
let manifest = (open ($temp_dir + "/manifest.json"))
let vars = (open ($temp_dir + "/vars.json"))
let script = (open -r ($temp_dir + "/script.sh"))
# Cleanup
^rm -rf $temp_dir
{
template_path: $manifest.template_path
template_vars: $vars
script: $script
timestamp: $manifest.timestamp
}
}

View file

@ -0,0 +1,255 @@
# Module: Service Availability Check Utilities
# Purpose: Reusable patterns for checking service availability before making requests
# Guidelines: Follows .claude/guidelines/provisioning.md - Service Check Pattern
#
# Features:
# - Check individual service availability
# - Check all essential services (cascade failure detection)
# - Check external dependencies (database, OCI registries, Git sources)
# - Clean error messages with short aliases
# - No stack traces (uses print + return, not error make)
use ../platform/target.nu *
use ../platform/health.nu *
use ../platform/service-manager.nu *
# Check external services locally (avoiding startup.nu import due to syntax errors in that file)
def check-external-services-internal [external_config: record]: nothing -> list {
let db = ($external_config.database? | default {backend: "filesystem"})
let oci_registries = ($external_config.oci_registries? | default [])
let git_sources = ($external_config.git_sources? | default [])
mut results = []
# Check database
if ($db.backend? | default "filesystem") == "filesystem" {
let path = ($db.path? | default "~/.provisioning/data")
let expanded_path = if ($path | str starts-with "~") {
$"($env.HOME)/($path | str substring 1..)"
} else {
$path
}
if ($expanded_path | path exists) {
$results = ($results | append {
service: "database"
backend: $db.backend
status: "✓"
message: $"Filesystem storage available at ($expanded_path)"
})
} else {
$results = ($results | append {
service: "database"
backend: $db.backend
status: "✗"
message: $"Path does not exist: ($expanded_path)"
})
}
}
$results
}
# Check if a service is available by verifying port is listening
# Returns: { available: bool, port: string, message: string }
export def check-service-available [
service_url: string # Service URL (e.g., "http://localhost:9011")
service_name: string # Human-readable service name (e.g., "Orchestrator")
]: nothing -> record {
# Extract port from URL
let parsed = ($service_url | parse "http://{host}:{port}")
let port = if ($parsed | is-empty) {
"unknown"
} else {
($parsed | get port.0)
}
# Check if port is listening (macOS: lsof, Linux: netstat fallback)
# Using do { } | complete pattern per Nushell guidelines (NO try-catch)
let port_check = (do { ^lsof -i :($port) -P -n | ^grep LISTEN } | complete)
let is_listening = ($port_check.exit_code == 0)
if $is_listening {
{
available: true,
port: $port,
message: $"($service_name) is available on port ($port)"
}
} else {
{
available: false,
port: $port,
message: $"($service_name) is not available on port ($port)"
}
}
}
# Check external services (database, OCI registries, Git sources)
# Returns list of external service statuses
export def check-external-services-status []: nothing -> list {
let external_services = (get-external-services)
if ($external_services | is-empty) {
return []
}
# get-external-services returns a table/list, we need to process each item
# For now, return simplified status based on what we can check
$external_services | each {|svc|
{
service: $svc.name
backend: ($svc.srvc? | default "external")
status: "✓"
message: $"External service: ($svc.name) at ($svc.url)"
}
}
}
# Check all platform services and return their status
# Returns list of {name: string, status: string, priority: int}
export def check-platform-services-status []: nothing -> list {
let services = (get-enabled-services)
$services | each {|svc|
let healthy = (check-service-health $svc.name)
{
name: $svc.name,
status: (if $healthy { "healthy" } else { "unhealthy" }),
priority: $svc.priority
}
}
}
# Show cascade failure report - prints static help without expensive service scanning
export def show-cascade-failure-report [failed_service: string]: nothing -> nothing {
print ""
print $"❌ ($failed_service) is not running."
print ""
print "Start all platform services:"
print " provisioning platform start"
print " prvng plat start # short alias"
print ""
print "Check service status:"
print " provisioning platform status"
print " prvng plat st # short alias"
print ""
}
# Verify service availability and fail with clean error message if not available
# This function prints error and returns error status (NO stack trace)
# Usage: Call this BEFORE making HTTP requests to services
export def verify-service-or-fail [
service_url: string # Service URL (e.g., "http://localhost:9011")
service_name: string # Human-readable service name (e.g., "Orchestrator")
--check-command: string = "" # Full command to check status
--check-alias: string = "" # Short alias for check (e.g., "prvng ps")
--start-command: string = "" # Full command to start service
--start-alias: string = "" # Short alias for start (e.g., "prvng start orchestrator")
]: nothing -> record {
let check_result = (check-service-available $service_url $service_name)
if not $check_result.available {
# Print clean error message WITHOUT stack trace (NO error make)
print $"❌ ($service_name) not available at ($service_url)"
print ""
print $"Connection refused - ($service_name) is not running on port ($check_result.port)."
print ""
# Show cascade failure report (external services + platform services)
show-cascade-failure-report $service_name
# Show commands with aliases
if ($check_command | is-not-empty) {
print "To check service status:"
print $" ($check_command)"
if ($check_alias | is-not-empty) {
print $" ($check_alias) # short alias"
}
print ""
}
if ($start_command | is-not-empty) {
print "To start service:"
print $" ($start_command)"
if ($start_alias | is-not-empty) {
print $" ($start_alias) # short alias"
}
print ""
}
print $"Current endpoint: ($service_url)"
print "If using a custom endpoint, verify it with: --orchestrator <url>"
# Return error status WITHOUT stack trace
return {status: "error", message: $"($service_name) not available"}
}
# Service is available
return {status: "ok", message: $"($service_name) is available"}
}
# Lightweight check - just returns boolean, no error message
export def is-service-available [
service_url: string # Service URL
service_name: string # Service name
]: nothing -> bool {
let check_result = (check-service-available $service_url $service_name)
$check_result.available
}
# Check if provisioning_daemon is available (CRITICAL - required for ALL operations)
# Returns: { available: bool, port: int }
export def check-daemon-availability []: nothing -> record {
# Get daemon configuration
let daemon_config = (get-deployment-service-config "provisioning_daemon")
let daemon_port = ($daemon_config.server?.port? | default 9095)
# Check if daemon port is listening
let port_check = (do { ^lsof -i :($daemon_port) -P -n | ^grep LISTEN } | complete)
let is_available = ($port_check.exit_code == 0)
{
available: $is_available
port: $daemon_port
}
}
# Verify daemon is available - CRITICAL prerequisite for ALL operations
# Blocks execution if daemon is not available (except for help, platform, setup)
# Returns error status if daemon unavailable
export def verify-daemon-or-block [
operation: string # Operation being attempted (for error message)
]: nothing -> record {
let daemon_check = (check-daemon-availability)
if not $daemon_check.available {
print ""
print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print "❌ CRITICAL: provisioning_daemon not available"
print "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
print ""
print $"The provisioning daemon is required for operation: ($operation)"
print $"Daemon is not listening on port ($daemon_check.port)"
print ""
print "The daemon is a CRITICAL component - all operations require it."
print ""
print "To check daemon status:"
print " provisioning platform status"
print " prvng plat st # short alias"
print ""
print "To start the daemon:"
print " provisioning platform start provisioning_daemon"
print " prvng plat start provisioning_daemon # short alias"
print ""
print "Allowed operations without daemon:"
print " • help / -h / --help - View help"
print " • platform <cmd> - Manage platform services"
print " • setup - Initial setup"
print ""
return {status: "error", message: "provisioning_daemon not available"}
}
# Daemon is available
return {status: "ok", message: "provisioning_daemon is available"}
}

Some files were not shown because too many files have changed in this diff Show more