feat(cli): prepare and validate workspace documents offline

Reuse the runtime catalog and workspace parsers in a standalone helper paired with tht. Add document templates, safe diagnostics, local Evidence checks, native bundle builds, shared CLI fixtures and IT/EN preparation guides. Record the approved document-first specification and ticket breakdown. Refs #43.
This commit is contained in:
Codex
2026-09-28 15:35:25 +02:00
parent 67ee52624c
commit 64e6b9664a
20 changed files with 2195 additions and 4 deletions
+7
View File
@@ -85,6 +85,10 @@ Commands:
pi maintenance recover --yes
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
workspace prepare --directory NEW_PATH --id ID --name NAME [--language en|it] [--json]
Prepare workspace documents locally, before installation.
workspace validate --directory PATH [--json]
Validate local workspace documents without starting services.
workspace inspect --workspace ID [--json]
workspace pull [--json]
Pull and activate the configured workspace repository.
@@ -141,6 +145,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
}
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
if command == "workspace" && len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "validate") {
return workspaceDocumentsCommand(ctx, commandArgs, stdout, stderr)
}
workingDirectory, err := os.Getwd()
if err != nil {
fmt.Fprintf(stderr, "tht: current directory is unavailable: %s\n", output.Sanitize(err.Error(), nil))
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
)
// Resolve only the packaged sibling, never an executable from the workspace or PATH.
func workspaceDocumentsCommand(ctx context.Context, args []string, stdout, stderr io.Writer) int {
executable, err := os.Executable()
if err == nil {
executable, err = filepath.EvalSymlinks(executable)
}
if err == nil {
name := "tht-workspace-documents"
if runtime.GOOS == "windows" {
name += ".exe"
}
command := exec.CommandContext(ctx, filepath.Join(filepath.Dir(executable), name), args...)
command.Stdout, command.Stderr = stdout, stderr
err = command.Run()
if err == nil {
return 0
}
var exitError *exec.ExitError
if errors.As(err, &exitError) && exitError.ExitCode() >= 0 {
return exitError.ExitCode()
}
}
const correction = "Install tht and tht-workspace-documents from the same platform bundle in the same directory, then retry."
if slices.Contains(args, "--json") {
_ = json.NewEncoder(stdout).Encode(map[string]any{
"schema_version": 1, "scope": "local-documents", "ok": false,
"workspaces": []any{}, "deferred_checks": []string{},
"issues": []map[string]string{{"document": "CLI", "field": "$", "code": "workspace_helper_unavailable", "correction": correction}},
})
} else {
fmt.Fprintln(stderr, correction)
}
return 1
}
@@ -0,0 +1,29 @@
package main
import (
"bytes"
"context"
"encoding/json"
"testing"
)
func TestWorkspaceDocumentsNeedsPackagedHelperNotInstallation(t *testing.T) {
t.Setenv("PATH", "")
t.Setenv("THOTHII_INSTALLATION", "/nonexistent/installation.yaml")
for _, action := range []string{"prepare", "validate"} {
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"workspace", action, "--directory", t.TempDir(), "--json"}, &stdout, &stderr)
var report struct {
OK bool `json:"ok"`
Issues []struct {
Code string `json:"code"`
} `json:"issues"`
}
if err := json.Unmarshal(stdout.Bytes(), &report); err != nil {
t.Fatalf("missing structured helper error: status=%d stdout=%s stderr=%s", status, &stdout, &stderr)
}
if status != 1 || report.OK || len(report.Issues) != 1 || report.Issues[0].Code != "workspace_helper_unavailable" || stderr.Len() != 0 {
t.Fatalf("unexpected missing-helper result: status=%d stdout=%s stderr=%s", status, &stdout, &stderr)
}
}
}