diff --git a/README.md b/README.md index 20f3712..c69a743 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Go Report Card][goReportBadge]][goReportLink] [![GitHub release (latest by date)][latestReleaseBadge]](https://github.com/HashLoad/boss/releases/latest) +[![SBOM Badge][sbomBadge]](https://www.pubpascal.dev) [![GitHub Release Date][releaseDateBadge]](https://github.com/HashLoad/boss/releases) [![GitHub repo size][repoSizeBadge]](https://github.com/HashLoad/boss/archive/refs/heads/main.zip) [![GitHub All Releases][totalDownloadsBadge]](https://github.com/HashLoad/boss/releases) @@ -39,281 +40,250 @@ Or you can use the following the steps below: ## ๐Ÿ“š Available Commands -### > Init +This section documents all commands supported by the Boss CLI, grouped in the exact order they appear in `boss --help`. -Initialize a new project and create a `boss.json` file. Add `-q` or `--quiet` to skip interactive prompts and use default values. +--- -```shell -boss init -boss init -q -boss init --quiet -``` +### 1. Available Commands (Legacy Core) -### > Install +These are the classic dependency management commands inherited from the original Boss engine. -Install one or more dependencies with real-time progress tracking: +#### > config +Manage configuration settings for Boss (e.g., Delphi paths, Git client settings, etc.): +```sh +# Set native Git client (recommended on Windows) +boss config git mode native -```shell -boss install +# Enable shallow cloning for faster dependency checkout +boss config git shallow true ``` -**Progress Tracking:** Boss displays progress for each dependency being installed: - +#### > dependencies +List all project dependencies in a tree format. Add `-v` to show version information: +```sh +boss dependencies +boss dependencies -v +boss dependencies ``` -โณ horse Waiting... -๐Ÿงฌ dataset-serialize Cloning... -๐Ÿ” jhonson Checking... -๐Ÿ”ฅ redis-client Installing... -๐Ÿ“ฆ boss-core Installed +> Aliases: `dep`, `ls`, `list`, `ll`, `la`, `dependency` + +#### > init +Initialize a new, minimal project configuration in the current directory and create a `boss.json` file: +```sh +boss init +boss init --quiet # Skip interactive prompts ``` -The dependency name is case insensitive. For example, `boss install horse` is the same as `boss install HORSE`. +#### > install +Install one or more dependencies defined in the `boss.json` file or add a new dependency: +```sh +# Install dependencies from boss.json +boss install -```shell -boss install horse # HashLoad organization on GitHub -boss install fake/horse # Fake organization on GitHub -boss install gitlab.com/fake/horse # Fake organization on GitLab -boss install https://gitlab.com/fake/horse # Full URL +# Add and install a new dependency +boss install github.com/HashLoad/horse ``` +> Aliases: `i`, `add` -You can also specify the compiler version and platform: - +#### > logout +Remove saved credentials for a private repository or registry: ```sh -boss install --compiler=37.0 --platform=Win64 +boss logout github.com/username ``` -> Aliases: i, add - -### > Uninstall - -Remove a dependency from the project: - +#### > uninstall +Remove a dependency from the current project: ```sh boss uninstall ``` +> Aliases: `remove`, `rm`, `r`, `un`, `unlink` -> Aliases: remove, rm, r, un, unlink - -### > Update - +#### > update Update all installed dependencies to their latest compatible versions: - ```sh boss update ``` +> Aliases: `up` -> Aliases: up - -### > Upgrade - -Upgrade the Boss CLI to the latest version. Add `--dev` to upgrade to the latest pre-release: - +#### > upgrade +Upgrade the Boss CLI client to the latest version: ```sh boss upgrade -boss upgrade --dev +boss upgrade --dev # Upgrade to the latest pre-release ``` -### > Dependencies +#### > version +Show the Boss CLI version: +```sh +boss version +boss --version +``` +> Aliases: `v` -List all project dependencies in a tree format. Add `-v` to show version information: +--- -```shell -boss dependencies -boss dependencies -v -boss dependencies -boss dependencies -v -``` +### 2. Available Commands (new) -> Aliases: dep, ls, list, ll, la, dependency +These commands add modern project creation, compiling, and script running capabilities to Boss. -### > Run +#### > new +Generate a fully structured Delphi or Lazarus project template (skeleton) in the current directory: +```sh +boss new my_project +boss new my_project --ide lazarus +boss new my_package --type pkg --ide lazarus +``` -Execute a custom script defined in your `boss.json` file. Scripts are defined in the `scripts` section: +#### > pkg +Perform Delphi package manifest operations. +* **`pkg spec`**: Scaffolds a starter `pubpascal.json` manifest file for the package: + ```sh + boss pkg spec --id my-package --pkgversion 1.0.0 + ``` +#### > run +Execute a custom shell script defined in the `scripts` section of your `boss.json` file: ```json { - "name": "my-project", "scripts": { - "build": "msbuild MyProject.dproj", - "test": "MyProject.exe --test", + "build": "msbuild MyProject.dproj /p:Config=Release", "clean": "del /s *.dcu" } } ``` - ```sh boss run build -boss run test boss run clean ``` -### > Login - -Register credentials for a repository. Useful for private repositories: - -```sh -boss login -boss login -u UserName -p Password -boss login -s -k PrivateKey -p PassPhrase # SSH authentication -``` +--- -> Aliases: adduser, add-user +### 3. Available Commands (pubpascal) -### > Logout - -Remove saved credentials for a repository: +These commands integrate your local development workflow with the PubPascal Portal. +#### > login +Authenticate your local environment with the PubPascal portal using a Personal Access Token (PAT): ```sh -boss logout +# Authenticate using a personal access token +boss login --token ``` +#### > contribute +Contribute to a third-party package by automating repository forking and Pull Request creation. +* **Fork & Setup**: Automatically forks the upstream package and configures your local git remotes (`origin` for your fork and `upstream` for the original): + ```sh + boss contribute github.com/HashLoad/horse + ``` +* **Submit Pull Request**: Once your commits are ready, push changes and submit a Pull Request to the original repository with one command: + ```sh + boss contribute github.com/HashLoad/horse --pr --title "Fix memory leak" --body "..." + ``` -### > Version - -Show the Boss CLI version: - -```shell -boss version -boss v -boss -v -boss --version -``` +#### > workspace +Manage multi-repository PubPascal workspaces locally. +* **`workspace clone`**: Clones a workspace and all its member repositories, checking out the reference each one is pinned to: + ```sh + boss workspace clone + boss workspace clone --codename my-branch + ``` +* **`workspace status`**: Show Git status (ahead/behind/dirty) for all repositories in the workspace: + ```sh + boss workspace status + ``` +* **`workspace update`**: Fast-forward each repository on its current branch: + ```sh + boss workspace update + ``` +* **`workspace push`**: Push committed changes for each repository that has an upstream branch: + ```sh + boss workspace push + ``` -> Aliases: v +--- -## Global Flags +### 4. Cyber Resilience Act (CRA) & SBOM -### > Global (-g) +These native commands help you achieve 100% Cyber Resilience Act (CRA) compliance. -Use global environment for installation. Packages installed globally are available system-wide: +#### > cra +Check your project's CRA compliance status or initialize required files automatically. +* **`cra` (Diagnose)**: Scan the local project for required CRA signals (Security Policy, SBOM). It exits with status `1` when a signal is missing, so it can be used as a CI gate: + ```sh + boss cra + ``` +* **`cra init` (Wizard)**: Start the interactive wizard to generate the `SECURITY.md` policy and `sbom.cdx.json` SBOM: + ```sh + boss cra init + boss cra init --email security@yourcompany.com # Silent/CI mode + ``` +#### > sbom +Generate a standard CycloneDX or SPDX Software Bill of Materials (SBOM) for your Delphi project: ```sh -boss install -g -boss --global install -``` - -### > Debug (-d) +# Generate CycloneDX SBOM (outputs to ./sbom/.cdx.json) +boss sbom -Enable debug mode to see detailed output: +# Specify custom project file and output path +boss sbom --project ./src/MyProj.dproj --output ./custom-sbom-folder -```sh -boss install --debug -boss -d install +# Generate in SPDX format +boss sbom --format spdx ``` -### > Help (-h) +--- -Show help for any command: +### 5. Additional Commands +#### > cache +Manage the Boss local cache to clear downloaded modules and free up disk space: ```sh -boss --help -boss --help +boss config cache rm ``` +> Aliases: `purge`, `clean` -## Configuration - -### > Cache - -Manage the Boss cache. Remove all cached modules to free up disk space: - +#### > completion +Generate the autocompletion script for the specified shell (bash, zsh, fish, or powershell): ```sh -boss config cache rm +boss completion powershell | Out-String | Invoke-Expression ``` -> Aliases: purge, clean - -### > Delphi Version +--- -You can configure which Delphi version BOSS should use for compilation. This is useful when you have multiple Delphi versions installed. +## Global Flags -#### List available versions +* **`-g, --global`**: Use global environment for installation (packages are available system-wide): + ```sh + boss install -g + ``` +* **`-d, --debug`**: Enable debug mode to see detailed output: + ```sh + boss install -d + ``` +* **`-h, --help`**: Show help for any command: + ```sh + boss --help + boss install --help + ``` +* **`-v, --version`**: Show CLI client version: + ```sh + boss --version + ``` -Lists all detected Delphi installations (32-bit and 64-bit) with their indexes. +## Configuration +### > Delphi Version +Configure which Delphi version Boss should use for compiling packages: ```sh +# List all detected Delphi installations boss config delphi list -``` - -#### Select a version -Selects a specific Delphi version to use globally. You can use the index from the list command, the version number, or the version with architecture. - -```sh +# Select a Delphi version to use globally boss config delphi use -# or -boss config delphi use -# or -boss config delphi use - -``` - -Example: -```sh -boss config delphi use 0 boss config delphi use 37.0 boss config delphi use 37.0-Win64 ``` -### > Git Client - -You can configure which Git client BOSS should use. - -- `embedded`: Uses the built-in go-git client (default). -- `native`: Uses the system's installed git client (git.exe). - -Using `native` is recommended on Windows if you need support for `core.autocrlf` (automatic line ending conversion). - -```sh -boss config git mode native -# or -boss config git mode embedded -``` - -#### Shallow Clone - -You can enable shallow cloning to significantly speed up dependency downloads. Shallow clones only fetch the latest commit without the full git history, reducing download size dramatically (e.g., from 127 MB to <1 MB for large repositories). - -```sh -# Enable shallow clone (faster, recommended for CI/CD) -boss config git shallow true - -# Disable shallow clone (full history) -boss config git shallow false -``` - -**Note:** Shallow clone is disabled by default to maintain compatibility. When enabled, you won't have access to the full git history of dependencies. - -You can also temporarily enable shallow clone using an environment variable: - -```sh -# Windows -set BOSS_GIT_SHALLOW=1 -boss install - -# Linux/macOS -BOSS_GIT_SHALLOW=1 boss install -``` -### > Project Toolchain - -You can also specify the required compiler version and platform in your project's `boss.json` file. This ensures that everyone working on the project uses the correct toolchain. - -Add a `toolchain` section to your `boss.json`: - -```json -{ - "name": "my-project", - "version": "1.0.0", - "toolchain": { - "compiler": "37.0", - "platform": "Win64" - } -} -``` - -Supported fields in `toolchain`: -- `compiler`: The compiler version (e.g., "37.0"). -- `platform`: The target platform ("Win32" or "Win64"). -- `path`: Explicit path to the compiler (optional). -- `strict`: If true, fails if the exact version is not found (optional). ## Samples @@ -491,14 +461,17 @@ Here's a comprehensive example showing all available fields: - `path`: Explicit path to the compiler (optional) - `strict`: If `true`, fails if the exact version is not found (default: `false`) -### Minimal boss.json +### Minimal boss.json (Classic Format) -The minimal valid `boss.json` file: +A basic, classic `boss.json` showing that Boss remains fully backwards-compatible and works out of the box with just dependency definitions: ```json { "name": "my-project", - "version": "1.0.0" + "version": "1.0.0", + "dependencies": { + "github.com/HashLoad/horse": "^3.0.0" + } } ``` @@ -583,3 +556,4 @@ boss init -q [telegramBadge]: https://img.shields.io/badge/telegram-join%20channel-7289DA?style=flat-square [telegramLink]: https://t.me/hashload [repoStarsBadge]: https://img.shields.io/github/stars/hashload/boss?style=social +[sbomBadge]: https://img.shields.io/badge/SBOM-compliant-brightgreen diff --git a/internal/adapters/primary/cli/cmd_test.go b/internal/adapters/primary/cli/cmd_test.go index 94cef2c..f2d8867 100644 --- a/internal/adapters/primary/cli/cmd_test.go +++ b/internal/adapters/primary/cli/cmd_test.go @@ -23,6 +23,7 @@ func TestRootCommand(t *testing.T) { t.Run("register commands", func(t *testing.T) { // These should not panic versionCmdRegister(root) + pubpascalCmdRegister(root) // Verify command was added if root.Commands() == nil { @@ -39,7 +40,7 @@ func TestVersionCommand(t *testing.T) { // Find the version command var versionCmd *cobra.Command for _, cmd := range root.Commands() { - if cmd.Use == "version" { + if cmd.Use == cmdNameVersion { versionCmd = cmd break } @@ -117,6 +118,8 @@ func TestCommandHelp(t *testing.T) { // Register all commands versionCmdRegister(root) installCmdRegister(root) + pubpascalCmdRegister(root) + craCmdRegister(root) for _, cmd := range root.Commands() { t.Run(cmd.Use, func(t *testing.T) { @@ -160,3 +163,57 @@ func TestRootHelp(t *testing.T) { t.Error("Root command should produce help output") } } + +// findCommand returns the direct sub-command of parent with the given name. +func findCommand(parent *cobra.Command, name string) *cobra.Command { + for _, cmd := range parent.Commands() { + if cmd.Name() == name { + return cmd + } + } + + return nil +} + +// assertSubcommands reports every expected sub-command missing from parent. +func assertSubcommands(t *testing.T, parent *cobra.Command, label string, names []string) { + t.Helper() + + for _, name := range names { + if findCommand(parent, name) == nil { + t.Errorf("%s subcommand '%s' not found", label, name) + } + } +} + +// TestPubPascalCommands tests that the PubPascal commands are registered correctly. +func TestPubPascalCommands(t *testing.T) { + root := &cobra.Command{Use: "boss"} + pubpascalCmdRegister(root) + + // Check workspace command and its subcommands + workspaceCmd := findCommand(root, "workspace") + if workspaceCmd == nil { + t.Fatal("Workspace command not found") + } + // list/search/diff/pull/commit are the sub-commands the PubPascal desktop + // app and the RAD Studio (OTA) plugin spawn. While they were missing, cobra + // answered "boss workspace pull" by printing the workspace help and exiting + // 0, which the app read as a successful pull. + assertSubcommands(t, workspaceCmd, "Workspace", []string{ + "clone", "status", "update", "push", + "list", "search", "diff", "pull", "commit", + }) + + // Check pkg command and root commands + pkgCmd := findCommand(root, "pkg") + if pkgCmd == nil { + t.Fatal("Pkg command not found") + } + if findCommand(root, "sbom") == nil { + t.Error("Root command 'sbom' not found") + } + + // Check pkg subcommands + assertSubcommands(t, pkgCmd, "Pkg", []string{"spec"}) +} diff --git a/internal/adapters/primary/cli/contribute.go b/internal/adapters/primary/cli/contribute.go new file mode 100644 index 0000000..bf50644 --- /dev/null +++ b/internal/adapters/primary/cli/contribute.go @@ -0,0 +1,360 @@ +package cli + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/hashload/boss/internal/core/domain" + "github.com/hashload/boss/pkg/env" + "github.com/hashload/boss/pkg/msg" + "github.com/spf13/cobra" +) + +// contributeRequestTimeout bounds the portal calls made by 'boss contribute'. +// Forking a repository on GitHub takes noticeably longer than a plain read. +const contributeRequestTimeout = 30 * time.Second + +// contributeCmdRegister registers the contribute command. +func contributeCmdRegister(root *cobra.Command) { + var prMode bool + var prTitle string + var prBody string + + var contributeCmd = &cobra.Command{ + Use: cmdNameContribute + " ", + Short: "Contribute to a third-party package by automating fork and Pull Request creation", + Long: "Contribute to a package. It automatically forks the repository, configures " + + "upstream/origin remotes, checkouts a new branch, and opens a Pull Request on GitHub " + + "once you are done.", + Example: ` Start contributing to a package: + boss contribute github.com/HashLoad/nidus + + Push changes and create a Pull Request on the upstream repository: + boss contribute github.com/HashLoad/nidus --pr --title "Fix memory leak" --body "..."`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + packageSlug := args[0] + config, err := LoadPubPascalConfig() + if err != nil { + msg.Die("โŒ Failed to load PubPascal config: %s", err) + } + + if config.AuthToken == "" { + msg.Die("โŒ Error: You must login first. Run 'boss login --token '") + } + + // Resolve local package folder + dep := domain.ParseDependency(packageSlug, "") + folderName := dep.Name() + pkgDir := filepath.Join(env.GetModulesDir(), folderName) + + if _, err := os.Stat(pkgDir); os.IsNotExist(err) { + msg.Die("โŒ Error: Package directory not found at %s. Ensure the package is installed.", pkgDir) + } + + if _, err := os.Stat(filepath.Join(pkgDir, ".git")); os.IsNotExist(err) { + msg.Die("โŒ Error: Directory %s is not a git repository.", pkgDir) + } + + if prMode { + handlePullRequestFlow(cmd.Context(), packageSlug, pkgDir, config, prTitle, prBody) + } else { + handleForkSetupFlow(cmd.Context(), packageSlug, pkgDir, config) + } + }, + } + + contributeCmd.Flags().BoolVar(&prMode, "pr", false, "push changes and open a Pull Request") + contributeCmd.Flags().StringVar(&prTitle, "title", "", "Pull Request title (defaults to last commit message)") + contributeCmd.Flags().StringVar(&prBody, "body", "", "Pull Request description (defaults to last commit body)") + root.AddCommand(contributeCmd) +} + +func handleForkSetupFlow(ctx context.Context, packageSlug string, pkgDir string, config *PubPascalConfig) { + msg.Info("๐Ÿด Requesting Fork from portal for %s...", packageSlug) + + // Call Portal API to fork + url := fmt.Sprintf("%s/api/packages/contribute/fork", strings.TrimSuffix(config.PortalBaseURL, "/")) + requestBody, _ := json.Marshal(map[string]string{ + "packageSlug": packageSlug, + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(requestBody)) + if err != nil { + msg.Die("โŒ Failed to create request: %s", err) + } + req.Header.Set("Authorization", "Bearer "+config.AuthToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: contributeRequestTimeout} + resp, err := client.Do(req) + if err != nil { + msg.Die("โŒ Connection error: %s", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := readPortalBody(resp) + if err != nil { + msg.Die("โŒ Failed to read portal response: %s", err) + } + if resp.StatusCode != http.StatusOK { + msg.Die("โŒ Fork failed (HTTP %d): %s", resp.StatusCode, portalErrorMessage(bodyBytes)) + } + + var res struct { + Success bool `json:"success"` + CloneURL string `json:"clone_url"` + SSHURL string `json:"ssh_url"` + Username string `json:"github_username"` + UpstreamO string `json:"upstream_owner"` + UpstreamR string `json:"upstream_repo"` + } + if err := json.Unmarshal(bodyBytes, &res); err != nil { + msg.Die("โŒ Failed to parse API response: %s", err) + } + + msg.Info("โœ… Fork created on GitHub under account: @%s", res.Username) + + // Git Automation: setup remotes + // 1. Rename origin to upstream (if upstream doesn't exist) + if !remoteExists(ctx, pkgDir, "upstream") { + msg.Info("โš™๏ธ Renaming remote 'origin' to 'upstream'...") + if _, err := runGitCmd(ctx, pkgDir, "remote", "rename", "origin", "upstream"); err != nil { + msg.Die("โŒ Failed to rename remote: %s", err) + } + } else { + msg.Warn("โš ๏ธ Remote 'upstream' already exists. Skipping remote renaming.") + } + + // 2. Add fork as origin + if remoteExists(ctx, pkgDir, "origin") { + msg.Info("โš™๏ธ Removing existing 'origin' remote...") + if _, err := runGitCmd(ctx, pkgDir, "remote", "remove", "origin"); err != nil { + msg.Die("โŒ Failed to remove old origin: %s", err) + } + } + + // Choose clone URL format (prefer SSH if git config contains git@ or SSH) + forkURL := res.CloneURL + if auth := env.GlobalConfiguration().Auth[depPrefix(packageSlug)]; auth != nil && auth.UseSSH { + forkURL = res.SSHURL + } else if strings.Contains(forkURL, "git@") { + forkURL = res.SSHURL + } + + msg.Info("โš™๏ธ Adding Fork URL as 'origin'...") + if _, err := runGitCmd(ctx, pkgDir, "remote", "add", "origin", forkURL); err != nil { + msg.Die("โŒ Failed to add origin remote: %s", err) + } + + // 3. Checkout contribution branch + branchName := generateBranchName() + msg.Info("โš™๏ธ Creating and checking out branch: %s...", branchName) + if _, err := runGitCmd(ctx, pkgDir, "checkout", "-b", branchName); err != nil { + msg.Die("โŒ Failed to checkout branch: %s", err) + } + + msg.Info("๐Ÿš€ Contribution environment successfully configured.") + msg.Info("๐Ÿ‘‰ You can now make your changes in the IDE, commit them, and run:") + msg.Info(" boss contribute %s --pr", packageSlug) +} + +func handlePullRequestFlow( + ctx context.Context, + packageSlug string, + pkgDir string, + config *PubPascalConfig, + title string, + body string, +) { + // 1. Resolve current branch + branch, err := runGitCmd(ctx, pkgDir, "rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + msg.Die("โŒ Failed to get current git branch: %s", err) + } + + if branch == "main" || branch == "master" || branch == "devel" { + msg.Die("โŒ Error: You are on the default branch '%s'. Please checkout your contribution branch first.", branch) + } + + // 2. Resolve title and body from last commit if not provided + if title == "" { + lastCommitTitle, titleErr := runGitCmd(ctx, pkgDir, "log", "-1", "--pretty=%s") + if titleErr == nil && lastCommitTitle != "" { + title = lastCommitTitle + } else { + title = "Contribution from PubPascal Dev-Flow" + } + } + + if body == "" { + lastCommitBody, bodyErr := runGitCmd(ctx, pkgDir, "log", "-1", "--pretty=%b") + if bodyErr == nil { + body = lastCommitBody + } + } + + msg.Info("๐Ÿš€ Pushing branch '%s' to your fork (origin)...", branch) + // --force-with-lease refuses the push when the remote moved since the last + // fetch, so a contribution can no longer overwrite work already published + // on the same branch. A plain --force gives no such protection. + if _, pushErr := runGitCmd(ctx, pkgDir, "push", "origin", branch, "--force-with-lease"); pushErr != nil { + msg.Die("โŒ Failed to push branch: %s", pushErr) + } + + msg.Info("๐Ÿ“จ Submitting Pull Request to portal...") + submitPullRequest(ctx, packageSlug, config, branch, title, body) +} + +// submitPullRequest asks the portal to open the Pull Request upstream. +func submitPullRequest( + ctx context.Context, + packageSlug string, + config *PubPascalConfig, + branch string, + title string, + body string, +) { + // Call Portal API to create PR + url := fmt.Sprintf("%s/api/packages/contribute/pr", strings.TrimSuffix(config.PortalBaseURL, "/")) + requestBody, _ := json.Marshal(map[string]string{ + "packageSlug": packageSlug, + "branch": branch, + "title": title, + "body": body, + }) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(requestBody)) + if err != nil { + msg.Die("โŒ Failed to create request: %s", err) + } + req.Header.Set("Authorization", "Bearer "+config.AuthToken) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: contributeRequestTimeout} + resp, err := client.Do(req) + if err != nil { + msg.Die("โŒ Connection error: %s", err) + } + defer func() { _ = resp.Body.Close() }() + + bodyBytes, err := readPortalBody(resp) + if err != nil { + msg.Die("โŒ Failed to read portal response: %s", err) + } + if resp.StatusCode != http.StatusOK { + msg.Die("โŒ Pull Request creation failed (HTTP %d): %s", + resp.StatusCode, portalErrorMessage(bodyBytes)) + } + + var res struct { + Success bool `json:"success"` + PrURL string `json:"pr_url"` + Head string `json:"head"` + Base string `json:"base"` + } + if err := json.Unmarshal(bodyBytes, &res); err != nil { + msg.Die("โŒ Failed to parse API response: %s", err) + } + + msg.Info("๐ŸŽ‰ Pull Request successfully created.") + msg.Info("๐Ÿ”— Access your PR here: %s", res.PrURL) +} + +// maxPortalResponseBytes caps how much of a portal response is read into +// memory. The payloads this command consumes are a handful of fields; anything +// larger is a misbehaving or hostile endpoint, and io.ReadAll would happily +// keep allocating for it. +const maxPortalResponseBytes = 1 << 20 // 1 MiB + +// readPortalBody reads a bounded portal response body. +func readPortalBody(resp *http.Response) ([]byte, error) { + return io.ReadAll(io.LimitReader(resp.Body, maxPortalResponseBytes)) +} + +// portalErrorMessage extracts the message of a portal error response. +// +// Decoding into map[string]string used to fail as a whole as soon as any value +// of the object was not a string -- a status code, a details object -- and the +// user was shown an empty reason. Only the field that matters is decoded now, +// and an unexpected payload falls back to the raw body instead of to nothing. +func portalErrorMessage(body []byte) string { + var errRes struct { + Error string `json:"error"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &errRes); err == nil { + if errRes.Error != "" { + return errRes.Error + } + if errRes.Message != "" { + return errRes.Message + } + } + + if raw := strings.TrimSpace(string(body)); raw != "" { + return raw + } + + return "the portal returned no details" +} + +// Helper to run git commands. +func runGitCmd(ctx context.Context, dir string, args ...string) (string, error) { + // #nosec G204 -- fixed git binary; arguments are built by this CLI + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("%w: %s", err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} + +// Helper to check if a git remote exists. +func remoteExists(ctx context.Context, dir string, remoteName string) bool { + out, err := runGitCmd(ctx, dir, "remote") + if err != nil { + return false + } + remotes := strings.Split(out, "\n") + for _, r := range remotes { + if strings.TrimSpace(r) == remoteName { + return true + } + } + return false +} + +// Helper to get prefix provider. +func depPrefix(repo string) string { + dep := domain.Dependency{Repository: repo} + return dep.GetURLPrefix() +} + +// generateBranchName builds a branch name unlikely to collide with an earlier +// contribution. The previous version drew from only 10,000 values, so two +// contributions could land on the same branch -- which, combined with a forced +// push, silently replaced the earlier one. +func generateBranchName() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + msg.Die("โŒ Failed to generate branch name: %s", err) + } + + return fmt.Sprintf("pubpascal/patch-%s", hex.EncodeToString(b[:])) +} diff --git a/internal/adapters/primary/cli/cra.go b/internal/adapters/primary/cli/cra.go new file mode 100644 index 0000000..dae8323 --- /dev/null +++ b/internal/adapters/primary/cli/cra.go @@ -0,0 +1,194 @@ +package cli + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hashload/boss/pkg/msg" + "github.com/spf13/cobra" +) + +const ( + // securityPolicyFile is the security disclosure policy generated by 'cra init'. + securityPolicyFile = "SECURITY.md" + // sbomBaseName is the base name passed to the SBOM generator; the generator + // appends the format extension. + sbomBaseName = "sbom" + // sbomFileName is the resulting CycloneDX file that 'cra' looks for. + sbomFileName = sbomBaseName + ".cdx.json" +) + +// craCmdRegister registers the cra commands under the boss CLI root. +func craCmdRegister(root *cobra.Command) { + var craCmd = &cobra.Command{ + Use: cmdNameCRA, + Short: "Cyber Resilience Act (CRA) compliance checker and assistant", + Long: `Diagnose and automate Cyber Resilience Act (CRA) compliance for your Delphi project. +Run without arguments to perform a local compliance check, or use 'cra init' to generate required files. +The check exits with status 1 when a required signal is missing, so it can gate a CI job.`, + Run: func(_ *cobra.Command, _ []string) { + runCraCheck() + }, + } + + var securityEmail string + + var initCmd = &cobra.Command{ + Use: "init", + Short: "Start interactive wizard to make your project 100% CRA compliant", + Long: "Start the interactive wizard to generate required Cyber Resilience Act (CRA) " + + "compliance files, such as SECURITY.md and sbom.cdx.json.", + Run: func(_ *cobra.Command, _ []string) { + runCraInit(securityEmail) + }, + } + + initCmd.Flags().StringVar(&securityEmail, "email", "", "Security contact email for reporting vulnerabilities") + + craCmd.AddCommand(initCmd) + root.AddCommand(craCmd) +} + +// runCraCheck performs a local diagnostic of the project against CRA/Portal signals. +func runCraCheck() { + msg.Info("๐Ÿ” Diagnosing Cyber Resilience Act (CRA) Compliance...\n") + + hasSecurity := false + securityCandidates := []string{securityPolicyFile, ".github/" + securityPolicyFile, "docs/" + securityPolicyFile} + for _, c := range securityCandidates { + if _, err := os.Stat(c); err == nil { + hasSecurity = true + msg.Info("โœ… Security Policy: Found security disclosure policy at '%s'", c) + break + } + } + if !hasSecurity { + msg.Warn("โŒ Security Policy: Missing 'SECURITY.md' in the repository.") + msg.Info(" -> To fix: Run 'boss cra init' to generate one automatically.") + } + + hasSbom := false + // boss sbom writes sbom/.cdx.json, so a glob is needed on top + // of the fixed names: matching only "sbom/sbom.cdx.json" meant the check + // never saw the file the generator had just produced. + sbomCandidates := []string{sbomFileName, "sbom.spdx.json", "bom.json"} + if globbed, err := filepath.Glob(filepath.Join(sbomBaseName, "*.cdx.json")); err == nil { + sbomCandidates = append(sbomCandidates, globbed...) + } + for _, c := range sbomCandidates { + if _, err := os.Stat(c); err == nil { + hasSbom = true + msg.Info("โœ… SBOM (Software Bill of Materials): Found SBOM file at '%s'", c) + break + } + } + if !hasSbom { + msg.Warn("โŒ SBOM (Software Bill of Materials): Missing 'sbom.cdx.json'.") + msg.Info(" -> To fix: Run 'boss sbom' or 'boss cra init' to generate it.") + } + + // Validate boss.json exists + if _, err := os.Stat(bossManifestFile); err != nil { + msg.Warn("โš ๏ธ boss.json: No boss.json found in current directory.") + } + + if hasSecurity && hasSbom { + msg.Info("\n๐ŸŽ‰ Your local project is 100%% CRA compliant! Commit and push these files " + + "to GitHub to get the Gold badge in the portal.") + + return + } + + msg.Info("\n๐Ÿ’ก Tips to get 100%% CRA badge:") + msg.Info("1. Run 'boss cra init' to let Boss generate the SECURITY.md and SBOM automatically.") + msg.Info("2. Commit and push the files to your repository.") + + // A checker that always exits 0 cannot gate anything: a CI job would + // report success on a project that has neither a security policy nor an + // SBOM. Non-compliant is a failure, so the exit status has to say so. + os.Exit(1) +} + +// runCraInit runs the interactive wizard to generate compliance files. +func runCraInit(securityEmail string) { + msg.Info("๐Ÿš€ Cyber Resilience Act (CRA) Compliance Wizard\n") + + // 1. Get security email + email := securityEmail + if email == "" { + reader := bufio.NewReader(os.Stdin) + // Written straight to stdout: msg.Info would append a line break and + // the answer has to be typed on the same line as the question. + _, _ = fmt.Fprint(os.Stdout, "๐Ÿ“ง Enter the email address to report security vulnerabilities: ") + input, err := reader.ReadString('\n') + if err != nil { + msg.Die("โŒ Failed to read email: %s", err) + } + email = strings.TrimSpace(input) + } + + if email == "" { + msg.Die("โŒ An email address is required to generate the security policy.") + } + + // 2. Generate SECURITY.md + //nolint:lll // reflowing the generated markdown would change the emitted file + securityContent := fmt.Sprintf(`# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities directly to the maintainers at the following address: + +๐Ÿ“ง **%s** + +Please do **NOT** open public issues or pull requests for security vulnerabilities until they have been reviewed and a fix is prepared. This keeps the report private and protects users of the package. + +We aim to acknowledge reports within a few business days and keep you updated on progress. + +## Supported Versions + +Security fixes are applied to the latest active release. We recommend always running the latest version of this package. +`, email) + + // Never clobber a policy the project already has: it may be the real one. + if _, err := os.Stat(securityPolicyFile); err == nil { + msg.Warn("โš ๏ธ '%s' already exists and was left untouched. Delete it first if you want "+ + "Boss to regenerate it.", securityPolicyFile) + } else if err := os.WriteFile(securityPolicyFile, []byte(securityContent), 0600); err != nil { + msg.Die("โŒ Failed to write %s: %s", securityPolicyFile, err) + } else { + msg.Info("โœ… Created '%s' with your security contact details.", securityPolicyFile) + } + + // 3. Generate the SBOM if boss.json is present + if _, err := os.Stat(bossManifestFile); err == nil { + msg.Info("๐Ÿ“ฆ boss.json detected. Generating Software Bill of Materials (SBOM)...") + + data, err := os.ReadFile(bossManifestFile) + if err == nil { + var manifest bossManifest + _ = json.Unmarshal(data, &manifest) + + // Written as the canonical file name that runCraCheck looks for. + // Naming it after the package would emit "/.cdx.json", + // which the compliance check cannot find -- and the slash in a + // scoped package name is not even a valid file name. + generateCycloneDxSbom(sbomBaseName, manifest, resolveSbomComponents(manifest), ".") + + if _, err := os.Stat(sbomFileName); err == nil { + msg.Info("โœ… Created '%s' in the project root.", sbomFileName) + } + } + } else { + msg.Warn("โš ๏ธ No boss.json found. Skipping SBOM generation. Create a boss.json first.") + } + + msg.Info("\n๐ŸŽ‰ Compliance files generated successfully!") + msg.Info("To get the 100%% CRA-ready badge in the PubPascal portal:") + msg.Info("1. Commit the newly created 'SECURITY.md' and 'sbom.cdx.json' to Git.") + msg.Info("2. Push them to your GitHub repository.") +} diff --git a/internal/adapters/primary/cli/dependencies.go b/internal/adapters/primary/cli/dependencies.go index 738780c..600d60d 100644 --- a/internal/adapters/primary/cli/dependencies.go +++ b/internal/adapters/primary/cli/dependencies.go @@ -54,7 +54,7 @@ func dependenciesCmdRegister(root *cobra.Command) { } root.AddCommand(dependenciesCmd) - dependenciesCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show dependency version") + dependenciesCmd.Flags().BoolVarP(&showVersion, flagNameVersion, "v", false, "show dependency version") } // printDependencies prints the dependencies. @@ -71,7 +71,9 @@ func printDependencies(showVersion bool) { main := tree.AddBranch(pkg.Name + ":") deps := pkg.GetParsedDependencies() - printDeps(nil, deps, pkg.Lock, main, showVersion) + visited := make(map[string]bool) + visited[pkg.Name] = true + printDeps(nil, deps, pkg.Lock, main, showVersion, visited) msg.Info(tree.String()) } @@ -80,7 +82,8 @@ func printDeps(dep *domain.Dependency, deps []domain.Dependency, lock domain.PackageLock, tree treeprint.Tree, - showVersion bool) { + showVersion bool, + visited map[string]bool) { var localTree treeprint.Tree if dep != nil { @@ -90,12 +93,25 @@ func printDeps(dep *domain.Dependency, } for _, dep := range deps { - pkgModule, err := pkgmanager.LoadPackageOther(filepath.Join(env.GetModulesDir(), dep.Name(), consts.FilePackage)) + name := dep.Name() + if visited[name] { + localTree.AddBranch(name + " <- circular dependency") + continue + } + + // Copy visited map to avoid side effects across sibling branches + newVisited := make(map[string]bool) + for k, v := range visited { + newVisited[k] = v + } + newVisited[name] = true + + pkgModule, err := pkgmanager.LoadPackageOther(filepath.Join(env.GetModulesDir(), name, consts.FilePackage)) if err != nil { printSingleDependency(&dep, lock, localTree, showVersion) } else { subDeps := pkgModule.GetParsedDependencies() - printDeps(&dep, subDeps, lock, localTree, showVersion) + printDeps(&dep, subDeps, lock, localTree, showVersion, newVisited) } } } diff --git a/internal/adapters/primary/cli/login.go b/internal/adapters/primary/cli/login.go index 7945273..62ee501 100644 --- a/internal/adapters/primary/cli/login.go +++ b/internal/adapters/primary/cli/login.go @@ -18,14 +18,22 @@ func loginCmdRegister(root *cobra.Command) { var privateKey string var userName string var password string + var portalToken string var loginCmd = &cobra.Command{ Use: "login", Short: "Add a registry user account", Example: ` Adding a new user account: - boss login `, + boss login + + Authenticating against the PubPascal portal: + boss login --token `, Aliases: []string{"adduser", "add-user"}, Run: func(_ *cobra.Command, args []string) { + if portalToken != "" { + runPortalLogin(portalToken) + return + } login(removeLogin, useSSH, privateKey, userName, password, args) }, } @@ -45,6 +53,8 @@ func loginCmdRegister(root *cobra.Command) { loginCmd.Flags().StringVarP(&privateKey, "key", "k", "", "Path of ssh private key") loginCmd.Flags().StringVarP(&userName, "username", "u", "", "Username") loginCmd.Flags().StringVarP(&password, "password", "p", "", "Password or PassPhrase(with SSH)") + loginCmd.Flags().StringVar(&portalToken, "token", "", + "PubPascal portal token; authenticates against the portal instead of a git registry") root.AddCommand(loginCmd) root.AddCommand(logoutCmd) diff --git a/internal/adapters/primary/cli/new.go b/internal/adapters/primary/cli/new.go index 0621a18..f3cbd6c 100644 --- a/internal/adapters/primary/cli/new.go +++ b/internal/adapters/primary/cli/new.go @@ -19,10 +19,18 @@ const ( defaultPackageVersion = "1.0.0" projectTypeApp = "app" projectTypePkg = "pkg" + + // srcDirName is the conventional sources directory of a Boss package. + srcDirName = "src" + + // ideDelphi and ideLazarus are the IDEs 'boss new' can scaffold for. + ideDelphi = "delphi" + ideLazarus = "lazarus" ) var ( projectType string //nolint:gochecknoglobals // cobra flag variable + targetIDE string //nolint:gochecknoglobals // cobra flag variable quietNew bool //nolint:gochecknoglobals // cobra flag variable ) @@ -62,6 +70,7 @@ contains end. ` +//nolint:lll // wrapping the element would change the generated .dproj const dprojTemplate = ` %s @@ -120,11 +129,100 @@ const dprojTemplate = ` - + ` +const lprTemplate = `program %s; + +{$mode objfpc}{$H+} + +uses + {$IFDEF UNIX} + cthreads, + {$ENDIF} + Classes, SysUtils; + +begin + WriteLn('Hello from %s!'); +end. +` + +const lpiTemplate = ` + + + + + + + + + + + + <UseAppBundle Value="False"/> + <ResourceType Value="res"/> + </General> + <BuildModes> + <Item Name="Default" Default="True"/> + </BuildModes> + <PublishOptions> + <Version Value="2"/> + <UseFileFilters Value="True"/> + </PublishOptions> + <RunParams> + <FormatVersion Value="2"/> + </RunParams> + <Units> + <Unit> + <Filename Value="%s.lpr"/> + <IsPartOfProject Value="True"/> + </Unit> + </Units> + </ProjectOptions> + <CompilerOptions> + <Version Value="11"/> + <Target> + <Filename Value="%s"/> + </Target> + <SearchPaths> + <IncludeFiles Value="$(ProjOutDir)"/> + <UnitOutputDirectory Value="lib/$(TargetCPU)-$(TargetOS)"/> + </SearchPaths> + </CompilerOptions> +</CONFIG> +` + +const lpkTemplate = `<?xml version="1.0" encoding="UTF-8"?> +<CONFIG> + <Package Version="5"> + <Name Value="%s"/> + <Type Value="RunAndDesignTime"/> + <CompilerOptions> + <Version Value="11"/> + <SearchPaths> + <UnitOutputDirectory Value="lib/$(TargetCPU)-$(TargetOS)"/> + </SearchPaths> + </CompilerOptions> + <Files> + <!-- Add package files here --> + </Files> + <RequiredPkgs> + <Item> + <PackageName Value="FCL"/> + </Item> + </RequiredPkgs> + <UsageOptions> + <UnitPath Value="$(PkgOutDir)"/> + </UsageOptions> + <PublishOptions> + <Version Value="2"/> + <UseFileFilters Value="True"/> + </PublishOptions> + </Package> +</CONFIG> +` + // generateGUID generates a random GUID in the standard {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} format. func generateGUID() string { b := make([]byte, 16) @@ -140,31 +238,35 @@ func generateGUID() string { func newCmdRegister(root *cobra.Command) { var newCmd = &cobra.Command{ Use: "new [project_name]", - Short: "Create a new Delphi project skeleton", - Long: "Create a new Delphi project skeleton with source directories, templates, and boss.json", + Short: "Create a new Delphi or Lazarus project skeleton", + Long: "Create a new Delphi or Lazarus project skeleton with source directories, templates, and boss.json", Args: cobra.MaximumNArgs(1), - Example: ` Create a new console application: + Example: ` Create a new console application (Delphi by default): boss new my_project - Create a new package/library: - boss new my_package --type pkg`, + Create a new Lazarus application: + boss new my_project --ide lazarus + + Create a new package/library in Lazarus: + boss new my_package --type pkg --ide lazarus`, Run: func(_ *cobra.Command, args []string) { var name string if len(args) > 0 { name = args[0] } - doCreateProject(name, projectType, quietNew) + doCreateProject(name, projectType, targetIDE, quietNew) }, } newCmd.Flags().StringVarP(&projectType, "type", "t", projectTypeApp, "type of project to generate (app or pkg)") + newCmd.Flags().StringVarP(&targetIDE, "ide", "i", "", "target IDE to generate for (delphi or lazarus)") newCmd.Flags().BoolVarP(&quietNew, "quiet", "q", false, "without asking questions") root.AddCommand(newCmd) } // doCreateProject performs the project creation. -func doCreateProject(name string, pType string, quiet bool) { +func doCreateProject(name string, pType string, ide string, quiet bool) { if !quiet && name == "" { name = getParamOrDef("Project name", "") } @@ -178,6 +280,16 @@ func doCreateProject(name string, pType string, quiet bool) { msg.Die("โŒ Invalid project type. Supported types: 'app' (default) or 'pkg'.") } + if !quiet && ide == "" { + ide = getParamOrDef("Target IDE (delphi or lazarus)", ideDelphi) + } + ide = strings.ToLower(strings.TrimSpace(ide)) + if ide == "l" || ide == ideLazarus { + ide = ideLazarus + } else { + ide = ideDelphi + } + cwd, err := os.Getwd() if err != nil { msg.Die("โŒ Failed to get current working directory: %v", err) @@ -188,12 +300,17 @@ func doCreateProject(name string, pType string, quiet bool) { msg.Die("โŒ Directory '%s' already exists.", name) } + ideTitle := "Delphi" + if ide == ideLazarus { + ideTitle = "Lazarus" + } + if !quiet { - msg.Info("๐Ÿš€ Creating a new Delphi project skeleton in %s...", projectDir) + msg.Info("๐Ÿš€ Creating a new %s project skeleton in %s...", ideTitle, projectDir) } // Create directories - srcDir := filepath.Join(projectDir, "src") + srcDir := filepath.Join(projectDir, srcDirName) testsDir := filepath.Join(projectDir, "tests") if err := os.MkdirAll(srcDir, 0750); err != nil { msg.Die("โŒ Failed to create src directory: %v", err) @@ -206,15 +323,55 @@ func doCreateProject(name string, pType string, quiet bool) { packageData := domain.NewPackage() packageData.Name = name packageData.Version = defaultPackageVersion - packageData.MainSrc = "src" + packageData.MainSrc = srcDirName packageJSONPath := filepath.Join(projectDir, consts.FilePackage) if err := pkgmanager.SavePackage(packageData, packageJSONPath); err != nil { msg.Die("โŒ Failed to save boss.json: %v", err) } - // Write Delphi files + // Write files based on the chosen IDE + if ide == ideLazarus { + writeLazarusProjectFiles(projectDir, name, pType) + } else { + writeDelphiProjectFiles(projectDir, name, pType) + } + + if !quiet { + msg.Info("โœจ Project '%s' created successfully!", name) + } +} + +// writeLazarusProjectFiles writes the .lpr/.lpi pair of an application, or the +// .lpk of a package. +func writeLazarusProjectFiles(projectDir string, name string, pType string) { + if pType == projectTypeApp { + lprPath := filepath.Join(projectDir, name+".lpr") + lprContent := fmt.Sprintf(lprTemplate, name, name) + if err := os.WriteFile(lprPath, []byte(lprContent), 0600); err != nil { + msg.Die("โŒ Failed to create .lpr project file: %v", err) + } + + lpiPath := filepath.Join(projectDir, name+".lpi") + lpiContent := fmt.Sprintf(lpiTemplate, name, name, name) + if err := os.WriteFile(lpiPath, []byte(lpiContent), 0600); err != nil { + msg.Die("โŒ Failed to create .lpi project file: %v", err) + } + + return + } + + lpkPath := filepath.Join(projectDir, name+".lpk") + lpkContent := fmt.Sprintf(lpkTemplate, name) + if err := os.WriteFile(lpkPath, []byte(lpkContent), 0600); err != nil { + msg.Die("โŒ Failed to create .lpk package file: %v", err) + } +} + +// writeDelphiProjectFiles writes the .dpr/.dpk source plus the .dproj project. +func writeDelphiProjectFiles(projectDir string, name string, pType string) { guid := generateGUID() + var dprojContent string if pType == projectTypeApp { dprPath := filepath.Join(projectDir, name+".dpr") @@ -236,8 +393,4 @@ func doCreateProject(name string, pType string, quiet bool) { if err := os.WriteFile(dprojPath, []byte(dprojContent), 0600); err != nil { msg.Die("โŒ Failed to create .dproj configuration file: %v", err) } - - if !quiet { - msg.Info("โœจ Project '%s' created successfully!", name) - } } diff --git a/internal/adapters/primary/cli/new_test.go b/internal/adapters/primary/cli/new_test.go index 7800002..d200305 100644 --- a/internal/adapters/primary/cli/new_test.go +++ b/internal/adapters/primary/cli/new_test.go @@ -39,11 +39,9 @@ func TestNewCommandRegistration(t *testing.T) { } typeFlag := newCmd.Flags().Lookup("type") - //nolint:staticcheck // Test intentionally keeps non-fatal assertion before the follow-up check if typeFlag == nil { - t.Error("New command should have --type flag") + t.Fatal("New command should have --type flag") } - //nolint:staticcheck // Test handles nil case with t.Error if typeFlag.DefValue != "app" { t.Errorf("New command --type flag default value should be 'app', got %s", typeFlag.DefValue) } @@ -57,9 +55,9 @@ func TestNewCommandRegistration(t *testing.T) { // TestDoCreateProject_App tests bootstrapping an application project. func TestDoCreateProject_App(t *testing.T) { tempDir := t.TempDir() - t.Chdir(tempDir) - var err error + // Redirect the working directory; t.Chdir restores it when the test ends. + t.Chdir(tempDir) // Initialize package manager fs := filesystem.NewOSFileSystem() @@ -69,42 +67,42 @@ func TestDoCreateProject_App(t *testing.T) { pkgmanager.SetInstance(packageService) projectName := "testapp" - doCreateProject(projectName, "app", true) + doCreateProject(projectName, "app", "delphi", true) projectPath := filepath.Join(tempDir, projectName) - if _, statErr := os.Stat(projectPath); os.IsNotExist(statErr) { + if _, err := os.Stat(projectPath); os.IsNotExist(err) { t.Fatalf("Project directory was not created") } // Check folders - if _, statErr := os.Stat(filepath.Join(projectPath, "src")); os.IsNotExist(statErr) { + if _, err := os.Stat(filepath.Join(projectPath, "src")); os.IsNotExist(err) { t.Error("src directory was not created") } - if _, statErr := os.Stat(filepath.Join(projectPath, "tests")); os.IsNotExist(statErr) { + if _, err := os.Stat(filepath.Join(projectPath, "tests")); os.IsNotExist(err) { t.Error("tests directory was not created") } // Check boss.json bossJSONPath := filepath.Join(projectPath, consts.FilePackage) - if _, statErr := os.Stat(bossJSONPath); os.IsNotExist(statErr) { + if _, err := os.Stat(bossJSONPath); os.IsNotExist(err) { t.Fatal("boss.json was not created") } - bossBytes, err := os.ReadFile(bossJSONPath) - if err != nil { - t.Fatalf("Failed to read boss.json: %v", err) + bossBytes, readErr := os.ReadFile(bossJSONPath) + if readErr != nil { + t.Fatalf("Failed to read boss.json: %v", readErr) } var pkg domain.Package - if err = json.Unmarshal(bossBytes, &pkg); err != nil { + if err := json.Unmarshal(bossBytes, &pkg); err != nil { t.Fatalf("Failed to parse boss.json: %v", err) } if pkg.Name != projectName { t.Errorf("Expected package name %q, got %q", projectName, pkg.Name) } - if pkg.Version != defaultPackageVersion { - t.Errorf("Expected package version %q, got %q", defaultPackageVersion, pkg.Version) + if pkg.Version != "1.0.0" { + t.Errorf("Expected package version '1.0.0', got %q", pkg.Version) } if pkg.MainSrc != "src" { t.Errorf("Expected mainsrc 'src', got %q", pkg.MainSrc) @@ -112,13 +110,13 @@ func TestDoCreateProject_App(t *testing.T) { // Check .dpr file dprPath := filepath.Join(projectPath, projectName+".dpr") - if _, statErr := os.Stat(dprPath); os.IsNotExist(statErr) { + if _, err := os.Stat(dprPath); os.IsNotExist(err) { t.Fatal(".dpr file was not created") } - dprBytes, err := os.ReadFile(dprPath) - if err != nil { - t.Fatalf("Failed to read .dpr file: %v", err) + dprBytes, readErr := os.ReadFile(dprPath) + if readErr != nil { + t.Fatalf("Failed to read .dpr file: %v", readErr) } dprContent := string(dprBytes) if !strings.Contains(dprContent, "program "+projectName) { @@ -127,13 +125,13 @@ func TestDoCreateProject_App(t *testing.T) { // Check .dproj file dprojPath := filepath.Join(projectPath, projectName+".dproj") - if _, statErr := os.Stat(dprojPath); os.IsNotExist(statErr) { + if _, err := os.Stat(dprojPath); os.IsNotExist(err) { t.Fatal(".dproj file was not created") } - dprojBytes, err := os.ReadFile(dprojPath) - if err != nil { - t.Fatalf("Failed to read .dproj file: %v", err) + dprojBytes, readErr := os.ReadFile(dprojPath) + if readErr != nil { + t.Fatalf("Failed to read .dproj file: %v", readErr) } dprojContent := string(dprojBytes) if !strings.Contains(dprojContent, "<ProjectGuid>") { @@ -147,9 +145,9 @@ func TestDoCreateProject_App(t *testing.T) { // TestDoCreateProject_Pkg tests bootstrapping a package project. func TestDoCreateProject_Pkg(t *testing.T) { tempDir := t.TempDir() - t.Chdir(tempDir) - var err error + // Redirect the working directory; t.Chdir restores it when the test ends. + t.Chdir(tempDir) // Initialize package manager fs := filesystem.NewOSFileSystem() @@ -159,22 +157,22 @@ func TestDoCreateProject_Pkg(t *testing.T) { pkgmanager.SetInstance(packageService) projectName := "testpkg" - doCreateProject(projectName, "pkg", true) + doCreateProject(projectName, "pkg", "delphi", true) projectPath := filepath.Join(tempDir, projectName) - if _, statErr := os.Stat(projectPath); os.IsNotExist(statErr) { + if _, err := os.Stat(projectPath); os.IsNotExist(err) { t.Fatalf("Project directory was not created") } // Check .dpk file dpkPath := filepath.Join(projectPath, projectName+".dpk") - if _, statErr := os.Stat(dpkPath); os.IsNotExist(statErr) { + if _, err := os.Stat(dpkPath); os.IsNotExist(err) { t.Fatal(".dpk file was not created") } - dpkBytes, err := os.ReadFile(dpkPath) - if err != nil { - t.Fatalf("Failed to read .dpk file: %v", err) + dpkBytes, readErr := os.ReadFile(dpkPath) + if readErr != nil { + t.Fatalf("Failed to read .dpk file: %v", readErr) } dpkContent := string(dpkBytes) if !strings.Contains(dpkContent, "package "+projectName) { @@ -183,16 +181,141 @@ func TestDoCreateProject_Pkg(t *testing.T) { // Check .dproj file dprojPath := filepath.Join(projectPath, projectName+".dproj") - if _, statErr := os.Stat(dprojPath); os.IsNotExist(statErr) { + if _, err := os.Stat(dprojPath); os.IsNotExist(err) { t.Fatal(".dproj file was not created") } - dprojBytes, err := os.ReadFile(dprojPath) - if err != nil { - t.Fatalf("Failed to read .dproj file: %v", err) + dprojBytes, readErr := os.ReadFile(dprojPath) + if readErr != nil { + t.Fatalf("Failed to read .dproj file: %v", readErr) } dprojContent := string(dprojBytes) if !strings.Contains(dprojContent, "<AppType>Package</AppType>") { t.Error("Expected .dproj to have AppType Package") } } + +// TestDoCreateProject_LazarusApp tests bootstrapping a Lazarus application project. +func TestDoCreateProject_LazarusApp(t *testing.T) { + tempDir := t.TempDir() + + // Redirect the working directory; t.Chdir restores it when the test ends. + t.Chdir(tempDir) + + // Initialize package manager + fs := filesystem.NewOSFileSystem() + packageRepo := repository.NewFilePackageRepository(fs) + lockRepo := repository.NewFileLockRepository(fs) + packageService := packages.NewPackageService(packageRepo, lockRepo) + pkgmanager.SetInstance(packageService) + + projectName := "testlazapp" + doCreateProject(projectName, "app", "lazarus", true) + + projectPath := filepath.Join(tempDir, projectName) + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Fatalf("Project directory was not created") + } + + // Check folders + if _, err := os.Stat(filepath.Join(projectPath, "src")); os.IsNotExist(err) { + t.Error("src directory was not created") + } + if _, err := os.Stat(filepath.Join(projectPath, "tests")); os.IsNotExist(err) { + t.Error("tests directory was not created") + } + + // Check boss.json + bossJSONPath := filepath.Join(projectPath, consts.FilePackage) + if _, err := os.Stat(bossJSONPath); os.IsNotExist(err) { + t.Fatal("boss.json was not created") + } + + bossBytes, readErr := os.ReadFile(bossJSONPath) + if readErr != nil { + t.Fatalf("Failed to read boss.json: %v", readErr) + } + + var pkg domain.Package + if err := json.Unmarshal(bossBytes, &pkg); err != nil { + t.Fatalf("Failed to parse boss.json: %v", err) + } + + if pkg.Name != projectName { + t.Errorf("Expected package name %q, got %q", projectName, pkg.Name) + } + + // Check .lpr file + lprPath := filepath.Join(projectPath, projectName+".lpr") + if _, err := os.Stat(lprPath); os.IsNotExist(err) { + t.Fatal(".lpr file was not created") + } + + lprBytes, readErr := os.ReadFile(lprPath) + if readErr != nil { + t.Fatalf("Failed to read .lpr file: %v", readErr) + } + lprContent := string(lprBytes) + if !strings.Contains(lprContent, "program "+projectName) { + t.Errorf("Expected .lpr file to contain program declaration") + } + + // Check .lpi file + lpiPath := filepath.Join(projectPath, projectName+".lpi") + if _, err := os.Stat(lpiPath); os.IsNotExist(err) { + t.Fatal(".lpi file was not created") + } + + lpiBytes, readErr := os.ReadFile(lpiPath) + if readErr != nil { + t.Fatalf("Failed to read .lpi file: %v", readErr) + } + lpiContent := string(lpiBytes) + if !strings.Contains(lpiContent, "<ProjectOptions>") { + t.Error("Expected .lpi to contain ProjectOptions tag") + } + if !strings.Contains(lpiContent, "<Filename Value=\""+projectName+".lpr\"/>") { + t.Error("Expected .lpi to reference the .lpr main unit") + } +} + +// TestDoCreateProject_LazarusPkg tests bootstrapping a Lazarus package project. +func TestDoCreateProject_LazarusPkg(t *testing.T) { + tempDir := t.TempDir() + + // Redirect the working directory; t.Chdir restores it when the test ends. + t.Chdir(tempDir) + + // Initialize package manager + fs := filesystem.NewOSFileSystem() + packageRepo := repository.NewFilePackageRepository(fs) + lockRepo := repository.NewFileLockRepository(fs) + packageService := packages.NewPackageService(packageRepo, lockRepo) + pkgmanager.SetInstance(packageService) + + projectName := "testlazpkg" + doCreateProject(projectName, "pkg", "lazarus", true) + + projectPath := filepath.Join(tempDir, projectName) + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Fatalf("Project directory was not created") + } + + // Check .lpk file + lpkPath := filepath.Join(projectPath, projectName+".lpk") + if _, err := os.Stat(lpkPath); os.IsNotExist(err) { + t.Fatal(".lpk file was not created") + } + + lpkBytes, readErr := os.ReadFile(lpkPath) + if readErr != nil { + t.Fatalf("Failed to read .lpk file: %v", readErr) + } + lpkContent := string(lpkBytes) + if !strings.Contains(lpkContent, "<Package Version=\"5\">") { + t.Error("Expected .lpk to contain Package Version 5 tag") + } + if !strings.Contains(lpkContent, "<Name Value=\""+projectName+"\"/>") { + t.Error("Expected .lpk to have the package name") + } +} diff --git a/internal/adapters/primary/cli/pubpascal.go b/internal/adapters/primary/cli/pubpascal.go new file mode 100644 index 0000000..3053020 --- /dev/null +++ b/internal/adapters/primary/cli/pubpascal.go @@ -0,0 +1,1423 @@ +package cli + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/hashload/boss/internal/adapters/secondary/filesystem" + "github.com/hashload/boss/internal/adapters/secondary/repository" + "github.com/hashload/boss/internal/core/domain" + "github.com/hashload/boss/pkg/consts" + "github.com/hashload/boss/pkg/msg" + "github.com/spf13/cobra" +) + +const ( + // portalRequestTimeout bounds every HTTP call made against the portal. + portalRequestTimeout = 15 * time.Second + + // bossManifestFile is the Boss manifest looked up inside a repository. + bossManifestFile = "boss.json" + + // modulesDirName holds the dependency repositories of a workspace root. + modulesDirName = "modules" + + // sourceDirName is the Delphi-style sources directory of a package; the + // "src" spelling is covered by srcDirName. + sourceDirName = "Source" + + // refKindTag and refKindVersion are the immutable reference kinds a + // workspace repository can be pinned to in the portal manifest. + refKindTag = "tag" + refKindVersion = "version" + + // defaultForgeHost is where a dependency without an explicit host lives. + defaultForgeHost = "github.com" + + // subCmdNameUpdate is the workspace sub-command that fast-forwards repos. + subCmdNameUpdate = "update" + + // sbomFormatCycloneDx and sbomFormatSpdx are the only values accepted by + // 'boss sbom --format'. + sbomFormatCycloneDx = "cyclonedx" + sbomFormatSpdx = "spdx" +) + +// bossManifest mirrors the subset of boss.json needed to build an SBOM. +type bossManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Homepage string `json:"homepage"` + Dependencies map[string]string `json:"dependencies"` +} + +// sbomComponent is one resolved dependency, ready to be emitted in any format. +type sbomComponent struct { + Name string + // Version is the exact version from the lock file, empty when unresolved. + Version string + // Constraint is the range declared in boss.json, kept for reference. + Constraint string + Purl string + Hash string + // Resolved reports whether Version came from the lock file (an exact + // version) rather than from a boss.json constraint such as "^3.0.0". + Resolved bool +} + +// PubPascalConfig represents the configuration stored in ~/.pubpascal/config.json. +type PubPascalConfig struct { + PortalBaseURL string `json:"portalBaseUrl"` + AuthToken string `json:"authToken"` +} + +// WorkspaceManifest represents the workspace manifest returned by the portal API. +type WorkspaceManifest struct { + SchemaVersion int `json:"schema_version"` + Workspace WorkspaceInfo `json:"workspace"` + Viewer ViewerInfo `json:"viewer"` + Repos []ManifestRepo `json:"repos"` + Edges []ManifestEdge `json:"edges"` +} + +// WorkspaceInfo identifies the workspace a manifest belongs to. +type WorkspaceInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ViewerInfo describes the permissions of the authenticated user on the workspace. +type ViewerInfo struct { + IsOwner bool `json:"is_owner"` +} + +// ManifestRepo is a single repository that takes part in the workspace. +type ManifestRepo struct { + NodeID string `json:"node_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Slug string `json:"slug"` + CloneURL string `json:"clone_url"` + IsRoot bool `json:"is_root"` + Writable bool `json:"writable"` + PushURL string `json:"push_url"` + Ref ManifestRef `json:"ref"` +} + +// ManifestRef is the git reference a workspace repository is pinned to. +type ManifestRef struct { + // The portal emits ref as {type, value} or null; there is no has_ref + // field. A zero ManifestRef therefore means "no pinned reference". + Kind string `json:"type"` + Value string `json:"value"` +} + +// ManifestEdge is a dependency relation between two workspace repositories. +type ManifestEdge struct { + FromNodeID string `json:"from_node_id"` + ToNodeID string `json:"to_node_id"` +} + +// GetPubPascalConfigPath resolves the path to the PubPascal config file. +func GetPubPascalConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(".pubpascal", "config.json") + } + return filepath.Join(home, ".pubpascal", "config.json") +} + +// LoadPubPascalConfig loads the PubPascal configuration from disk. +func LoadPubPascalConfig() (*PubPascalConfig, error) { + configPath := GetPubPascalConfigPath() + config := &PubPascalConfig{ + PortalBaseURL: "https://www.pubpascal.dev", + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return config, nil + } + + // #nosec G304 -- the path is this CLI's own config in the user's home + data, err := os.ReadFile(configPath) + if err != nil { + return config, err + } + + if err := json.Unmarshal(data, config); err != nil { + return config, err + } + + return config, nil +} + +// SavePubPascalConfig saves the PubPascal configuration to disk. +func SavePubPascalConfig(config *PubPascalConfig) error { + configPath := GetPubPascalConfigPath() + dir := filepath.Dir(configPath) + + // 0700/0600 are owner-only modes on POSIX. On Windows -- the primary Delphi + // platform -- Go maps only the 0200 write bit onto the read-only attribute + // and ignores the rest, so the file is reported as 0666 and the mode buys + // no protection there; the token is protected only by the ACLs the user + // profile directory already carries. + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + // Persisting the token is the whole purpose of this file: it is written + // into the user's home and never sent anywhere else. + // #nosec G117 -- the portal token is stored locally on purpose + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0600) +} + +// pubpascalCmdRegister registers the workspace and pkg commands under the boss CLI. +func pubpascalCmdRegister(root *cobra.Command) { + workspaceCmdRegister(root) + pkgCmdRegister(root) +} + +// workspaceCmdRegister registers the workspace commands. +func workspaceCmdRegister(root *cobra.Command) { + var workspaceCmd = &cobra.Command{ + Use: cmdNameWorkspace, + Short: "Multi-repository PubPascal workspace operations", + Long: "Multi-repository PubPascal workspace operations", + } + + var codename string + var noInstall bool + + var cloneCmd = &cobra.Command{ + Use: "clone <workspace-id>", + Short: "Clone a workspace and all its member repositories", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runWorkspaceClone(cmd.Context(), args[0], codename, noInstall) + }, + } + + cloneCmd.Flags().StringVar(&codename, "codename", "", "Create work branches suffixed with this codename") + cloneCmd.Flags().BoolVar(&noInstall, "no-install", false, "Skip automatic boss install in cloned repositories") + + var updateCmd = &cobra.Command{ + Use: subCmdNameUpdate, + Short: "Fast-forward each repository in the workspace to its pinned reference", + Run: func(cmd *cobra.Command, _ []string) { + runWorkspaceUpdate(cmd.Context()) + }, + } + + var pushCmd = &cobra.Command{ + Use: "push", + Short: "Push committed changes in writable repositories in the workspace", + Run: func(cmd *cobra.Command, _ []string) { + runWorkspacePush(cmd.Context()) + }, + } + + workspaceCmd.AddCommand(cloneCmd) + workspaceCmd.AddCommand(updateCmd) + workspaceCmd.AddCommand(pushCmd) + + // Portal-backed and git-only sub-commands, built in their own files. + workspaceCmd.AddCommand(newWorkspaceStatusCmd()) + workspaceCmd.AddCommand(newWorkspaceListCmd()) + workspaceCmd.AddCommand(newWorkspaceSearchCmd()) + workspaceCmd.AddCommand(newWorkspaceDiffCmd()) + workspaceCmd.AddCommand(newWorkspacePullCmd()) + workspaceCmd.AddCommand(newWorkspaceCommitCmd()) + + root.AddCommand(workspaceCmd) +} + +// pkgCmdRegister registers the pkg commands. +func pkgCmdRegister(root *cobra.Command) { + var pkgCmd = &cobra.Command{ + Use: projectTypePkg, + Short: "Delphi package manifest operations", + Long: "Delphi package manifest operations", + } + + var projectFile string + var format string + // Each command owns its output directory variable. Sharing one variable + // across commands makes the last registered default win for all of them, + // because StringVar assigns the default at registration time. + var sbomOutputDir string + + var sbomCmd = &cobra.Command{ + Use: sbomBaseName, + Short: "Generate a CycloneDX or SPDX SBOM for a Delphi project", + Long: "Generate a CycloneDX or SPDX SBOM (Software Bill of Materials) for a Delphi " + + "project (.dproj) file to analyze package dependencies.", + Run: func(_ *cobra.Command, _ []string) { + runPkgSbom(projectFile, format, sbomOutputDir) + }, + } + + sbomCmd.Flags().StringVar(&projectFile, "project", "", "Path to the Delphi .dproj file") + sbomCmd.Flags().StringVar(&format, "format", sbomFormatCycloneDx, + fmt.Sprintf("SBOM format (%s or %s)", sbomFormatCycloneDx, sbomFormatSpdx)) + sbomCmd.Flags().StringVar(&sbomOutputDir, "output", "./sbom", "Directory to write the SBOM to") + + var specID string + var specVersion string + + var specCmd = &cobra.Command{ + Use: "spec", + Short: "Scaffold a starter pubpascal.json manifest file", + Run: func(_ *cobra.Command, _ []string) { + runPkgSpec(specID, specVersion) + }, + } + + specCmd.Flags().StringVar(&specID, "id", "", "The package ID (slug) to scaffold") + specCmd.Flags().StringVar(&specVersion, "pkgversion", defaultPackageVersion, "The package version") + + pkgCmd.AddCommand(specCmd) + root.AddCommand(pkgCmd) + + root.AddCommand(sbomCmd) +} + +// workspaceCloneOptions carries the flags that change how each repository of a +// workspace is cloned. +type workspaceCloneOptions struct { + codename string + noInstall bool +} + +// cloneOutcome is the result of cloning a single workspace repository. +type cloneOutcome int + +const ( + cloneSucceeded cloneOutcome = iota + cloneSkipped + cloneFailed +) + +// runWorkspaceClone executes the clone workspace operation. +func runWorkspaceClone(ctx context.Context, workspaceID string, codename string, noInstall bool) { + config, err := LoadPubPascalConfig() + if err != nil { + msg.Die("โŒ Failed to load PubPascal configuration: %s", err) + } + + if config.AuthToken == "" { + msg.Die("โŒ You must log in first. Run 'boss login' with your portal token.") + } + + workspaceID = resolveWorkspaceRef(ctx, config, workspaceID) + + manifest := fetchWorkspaceManifest(ctx, config, workspaceID) + + msg.Info("Workspace: %s (%s)", manifest.Workspace.Name, manifest.Workspace.Description) + + cwd, err := os.Getwd() + if err != nil { + msg.Die("โŒ Failed to get current directory: %s", err) + } + + // Resolve the root repo name + rootRepoName := resolveRootRepoName(manifest.Repos) + if rootRepoName == "" { + msg.Die("โŒ Invalid manifest: no root (PAI) repository declared.") + } + + // Sort repos so root is cloned first + orderedRepos := orderReposRootFirst(manifest.Repos) + opts := workspaceCloneOptions{codename: codename, noInstall: noInstall} + + successCount := 0 + failCount := 0 + skipCount := 0 + + for i, repo := range orderedRepos { + // Resolve the subdirectory + repoNameOnly := repoShortName(repo.Name) + if repoNameOnly == "" { + msg.Warn("โš ๏ธ Skipping repository with an unusable name %q in the manifest.", repo.Name) + skipCount++ + continue + } + repoSubdir := repoNameOnly + if !repo.IsRoot { + repoSubdir = filepath.Join(rootRepoName, modulesDirName, repoNameOnly) + } + + msg.Info("[%d/%d] Cloning %s into %s...", i+1, len(orderedRepos), repo.CloneURL, repoSubdir) + + switch cloneWorkspaceRepo(ctx, repo, filepath.Join(cwd, repoSubdir), repoSubdir, opts) { + case cloneSucceeded: + successCount++ + case cloneSkipped: + skipCount++ + case cloneFailed: + failCount++ + } + } + + // Inject dproj paths for dependencies + injectDprojPaths(cwd, manifest.Repos, rootRepoName) + + msg.Info("\nClone summary: %d succeeded, %d skipped, %d failed.", successCount, skipCount, failCount) + if failCount > 0 { + os.Exit(1) + } +} + +// isWorkspaceUUID reports whether the identifier is already a workspace UUID. +// The manifest endpoint rejects anything else outright, so a non-UUID has to be +// resolved before it can be used. +func isWorkspaceUUID(id string) bool { + if len(id) != 36 { + return false + } + + for i, r := range id { + switch i { + case 8, 13, 18, 23: + if r != '-' { + return false + } + default: + isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') + if !isHex { + return false + } + } + } + + return true +} + +// resolveWorkspaceRef turns a "<package-slug>@<version>" identifier into the +// workspace UUID the manifest endpoint expects, passing UUIDs through untouched. +// +// The portal offers the workspace clone command in this form -- "a workspace IS +// the PAI at a version" -- but the manifest route accepts only UUIDs, so the +// command shown in the web UI failed with "workspace not found" until the CLI +// learned to call /api/workspaces/resolve first. +func resolveWorkspaceRef(ctx context.Context, config *PubPascalConfig, ref string) string { + if isWorkspaceUUID(ref) { + return ref + } + + msg.Info("Resolving workspace reference %s...", flattenDetail(ref)) + resolveURL := fmt.Sprintf("%s/api/workspaces/resolve?ref=%s", + strings.TrimSuffix(config.PortalBaseURL, "/"), url.QueryEscape(ref)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, resolveURL, nil) + if err != nil { + msg.Die("โŒ Failed to create HTTP request: %s", flattenDetail(err.Error())) + } + req.Header.Set("Authorization", "Bearer "+config.AuthToken) + + client := &http.Client{Timeout: portalRequestTimeout} + resp, err := client.Do(req) + if err != nil { + msg.Die("โŒ Network error: %s", flattenDetail(err.Error())) + } + defer func() { _ = resp.Body.Close() }() + + var payload struct { + ID string `json:"id"` + Name string `json:"name"` + Hint string `json:"hint"` + Candidates []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"candidates"` + } + _ = json.NewDecoder(resp.Body).Decode(&payload) + + switch resp.StatusCode { + case http.StatusOK: + if payload.ID == "" { + msg.Die("โŒ The portal resolved %s but returned no workspace id.", flattenDetail(ref)) + } + msg.Info(" Resolved to workspace %s (%s)", flattenDetail(payload.Name), flattenDetail(payload.ID)) + + return payload.ID + case http.StatusBadRequest: + hint := flattenDetail(payload.Hint) + if hint == "" { + hint = "expected <package-slug>@<version>, e.g. janus@1.0" + } + msg.Die("โŒ %s is not a workspace id nor a valid reference: %s", flattenDetail(ref), hint) + case http.StatusUnauthorized, http.StatusForbidden: + msg.Die("โŒ Unauthorized. Your portal auth token is invalid or expired.") + case http.StatusNotFound: + msg.Die("โŒ No workspace of yours has %s as its root (PAI).", flattenDetail(ref)) + case http.StatusConflict: + msg.Warn("โš ๏ธ %s matches more than one of your workspaces:", flattenDetail(ref)) + for _, c := range payload.Candidates { + msg.Warn(" %s %s", flattenDetail(c.ID), flattenDetail(c.Name)) + } + msg.Die("โŒ Ambiguous reference. Clone by workspace id instead.") + default: + msg.Die("โŒ Portal returned HTTP status %d while resolving %s", resp.StatusCode, flattenDetail(ref)) + } + + return ref +} + +// fetchWorkspaceManifest downloads the workspace manifest from the portal. +// +// It lives in its own function so the response body is closed as soon as the +// manifest has been read, instead of staying open for the whole clone -- which +// is what happened while this code was inlined in runWorkspaceClone, a function +// that ends with os.Exit and therefore never runs deferred calls. The msg.Die +// calls below still bypass the deferred Close, so the guarantee is "closed on +// the paths that return", not "always closed". +func fetchWorkspaceManifest(ctx context.Context, config *PubPascalConfig, workspaceID string) WorkspaceManifest { + msg.Info("Fetching workspace manifest for %s...", flattenDetail(workspaceID)) + manifestURL := fmt.Sprintf("%s/api/workspaces/%s/manifest", + strings.TrimSuffix(config.PortalBaseURL, "/"), workspaceID) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURL, nil) + if err != nil { + msg.Die("โŒ Failed to create HTTP request: %s", flattenDetail(err.Error())) + } + req.Header.Set("Authorization", "Bearer "+config.AuthToken) + + client := &http.Client{Timeout: portalRequestTimeout} + resp, err := client.Do(req) + if err != nil { + msg.Die("โŒ Network error: %s", flattenDetail(err.Error())) + } + defer func() { _ = resp.Body.Close() }() + + switch { + case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: + msg.Die("โŒ Unauthorized. Your portal auth token is invalid or expired.") + case resp.StatusCode == http.StatusNotFound: + msg.Die("โŒ Workspace %s not found on the portal.", flattenDetail(workspaceID)) + case resp.StatusCode != http.StatusOK: + msg.Die("โŒ Portal returned HTTP status %d", resp.StatusCode) + } + + var manifest WorkspaceManifest + if decodeErr := json.NewDecoder(resp.Body).Decode(&manifest); decodeErr != nil { + // A decoder error quotes the offending input, so a malformed body could + // smuggle a brace into the output the PubPascal host parses. + msg.Die("โŒ Failed to parse manifest JSON: %s", flattenDetail(decodeErr.Error())) + } + + return manifest +} + +// resolveRootRepoName returns the directory name of the root (PAI) repository. +// repoShortName returns the repository segment of an "owner/repo" manifest +// name. Indexing the split directly panics when the portal returns a name +// without a slash, which is data this client does not control. +// The result is used to build paths on disk, and name comes from the portal +// API, so traversal candidates are rejected rather than joined into a path. +func repoShortName(name string) string { + short := name + if idx := strings.LastIndex(name, "/"); idx != -1 && idx+1 < len(name) { + short = name[idx+1:] + } + + short = strings.TrimSpace(short) + if short == "" || short == "." || short == ".." || + strings.ContainsAny(short, `/\`) || strings.Contains(short, "..") { + return "" + } + + return short +} + +func resolveRootRepoName(repos []ManifestRepo) string { + for _, repo := range repos { + if repo.IsRoot { + return repoShortName(repo.Name) + } + } + + return "" +} + +// orderReposRootFirst orders the repositories so the root one is cloned first. +func orderReposRootFirst(repos []ManifestRepo) []ManifestRepo { + var ordered []ManifestRepo + for _, repo := range repos { + if repo.IsRoot { + ordered = append([]ManifestRepo{repo}, ordered...) + } else { + ordered = append(ordered, repo) + } + } + + return ordered +} + +// cloneWorkspaceRepo clones a single workspace repository, checks out its +// pinned reference and, when asked for, creates the codename branch and runs +// 'boss install' inside it. +func cloneWorkspaceRepo( + ctx context.Context, + repo ManifestRepo, + repoPath string, + repoSubdir string, + opts workspaceCloneOptions, +) cloneOutcome { + // Check if directory exists and is populated + if isDirPopulated(repoPath) { + msg.Warn(" Skipped โ€” directory already exists: %s", repoSubdir) + return cloneSkipped + } + + // Perform git clone + if err := os.MkdirAll(filepath.Dir(repoPath), 0750); err != nil { + msg.Err(" Failed to create directory: %s", err) + return cloneFailed + } + + // #nosec G204 -- fixed git binary; URL and path come from the portal manifest + cloneCmd := exec.CommandContext(ctx, "git", "clone", repo.CloneURL, repoPath) + cloneCmd.Stdout = os.Stdout + cloneCmd.Stderr = os.Stderr + if err := cloneCmd.Run(); err != nil { + msg.Err(" Failed to clone %s (git clone exited with error)", repo.CloneURL) + return cloneFailed + } + + // Checkout ref if specified + if repo.Ref.Value != "" { + // #nosec G204 -- fixed git binary; ref comes from the portal manifest + checkoutCmd := exec.CommandContext(ctx, "git", "-C", repoPath, "checkout", repo.Ref.Value) + checkoutCmd.Stdout = os.Stdout + checkoutCmd.Stderr = os.Stderr + if err := checkoutCmd.Run(); err != nil { + msg.Err(" Failed to checkout ref %s", repo.Ref.Value) + return cloneFailed + } + msg.Info(" Checked out ref: %s", repo.Ref.Value) + } + + // Create codename branch if specified, writable, and is branch/default ref + if opts.codename != "" && repo.Writable && isBranchOrDefaultRef(repo.Ref) { + createCodenameBranch(ctx, repoPath, opts.codename) + } + + // Run boss install if not skipped and boss.json exists + if !opts.noInstall { + runBossInstall(ctx, repoPath, repoSubdir) + } + + return cloneSucceeded +} + +// createCodenameBranch creates "<current-branch>-<codename>" in repoPath. +// Failures are not fatal: the repository is usable without the work branch. +func createCodenameBranch(ctx context.Context, repoPath string, codename string) { + // Get current branch + out, ok := gitCapture(ctx, repoPath, "rev-parse", "--abbrev-ref", gitHeadRef) + if !ok { + return + } + + baseBranch := strings.TrimSpace(out) + if baseBranch == gitHeadRef || baseBranch == "" { + return + } + + newBranch := baseBranch + "-" + codename + // #nosec G204 -- fixed git binary; branch name derives from the repo HEAD + createBranchCmd := exec.CommandContext(ctx, "git", "-C", repoPath, "checkout", "-b", newBranch) + if err := createBranchCmd.Run(); err != nil { + msg.Warn(" Warning: could not create branch %s (continuing)", newBranch) + return + } + + msg.Info(" Created and switched to branch: %s", newBranch) +} + +// runBossInstall runs 'boss install' in a freshly cloned repository that ships +// a boss.json. A failure is reported but never aborts the clone. +func runBossInstall(ctx context.Context, repoPath string, repoSubdir string) { + if _, err := os.Stat(filepath.Join(repoPath, bossManifestFile)); err != nil { + return + } + + msg.Info(" Running 'boss install' in %s...", repoSubdir) + bossCmd := exec.CommandContext(ctx, appName, "install") + bossCmd.Dir = repoPath + bossCmd.Stdout = os.Stdout + bossCmd.Stderr = os.Stderr + if err := bossCmd.Run(); err != nil { + msg.Warn(" Warning: 'boss install' failed in %s (continuing)", repoSubdir) + } +} + +// gitCapture runs git inside path and returns its standard output. The boolean +// reports whether git exited successfully; callers that only need the output +// treat a failure as "no information available". +func gitCapture(ctx context.Context, path string, args ...string) (string, bool) { + // #nosec G204 -- fixed git binary; the arguments are built by this CLI + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", path}, args...)...) + var out bytes.Buffer + cmd.Stdout = &out + err := cmd.Run() + + return out.String(), err == nil +} + +// isGitRepo reports whether path holds a .git entry. +func isGitRepo(path string) bool { + _, err := os.Stat(filepath.Join(path, ".git")) + + return err == nil +} + +func isDirPopulated(path string) bool { + // #nosec G304 -- path is built from the workspace manifest, not user input + f, err := os.Open(path) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + _, err = f.Readdirnames(1) + + return !errors.Is(err, io.EOF) +} + +func isBranchOrDefaultRef(ref ManifestRef) bool { + return ref.Value == "" || (ref.Kind != refKindTag && ref.Kind != refKindVersion) +} + +// injectDprojPaths updates the root project's .dproj file to include dependency search paths. +func injectDprojPaths(cwd string, repos []ManifestRepo, rootRepoName string) { + rootRepoPath := filepath.Join(cwd, rootRepoName) + // Find all .dproj files in the root repo + files, err := filepath.Glob(filepath.Join(rootRepoPath, "*.dproj")) + if err != nil || len(files) == 0 { + return + } + + dprojPath := files[0] + msg.Info("Updating search paths in Delphi project: %s", filepath.Base(dprojPath)) + + paths := collectDependencySearchPaths(rootRepoPath, repos) + if len(paths) == 0 { + return + } + + // #nosec G304 -- the path is a .dproj discovered inside the cloned workspace + content, err := os.ReadFile(dprojPath) + if err != nil { + return + } + + updatedXML, ok := mergeDprojSearchPaths(string(content), paths) + if !ok { + return + } + + // os.WriteFile keeps the mode of an already existing file, so the value + // below only applies if the .dproj disappeared between read and write. + // #nosec G703 -- dprojPath comes from a Glob inside the cloned workspace + if err := os.WriteFile(dprojPath, []byte(updatedXML), 0600); err != nil { + msg.Err("โŒ Failed to save updated .dproj file: %s", err) + } else { + msg.Info(" Delphi search paths updated successfully.") + } +} + +// collectDependencySearchPaths returns the search path of every non-root +// repository, relative to the root repository directory. +func collectDependencySearchPaths(rootRepoPath string, repos []ManifestRepo) []string { + var paths []string + for _, repo := range repos { + if repo.IsRoot { + continue + } + repoNameOnly := repoShortName(repo.Name) + if repoNameOnly == "" { + continue + } + // Determine the source path of the dependency. + // Boss packages usually have their sources in "Source" or "src" or root. + // We default to "Source" and check if it exists, otherwise fall back to + // the repository root or to "src". + depPath := filepath.Join(rootRepoPath, modulesDirName, repoNameOnly) + sourceDir := sourceDirName + if _, statErr := os.Stat(filepath.Join(depPath, srcDirName)); statErr == nil { + sourceDir = srcDirName + } else if _, statErr := os.Stat(filepath.Join(depPath, sourceDirName)); statErr != nil { + sourceDir = "" + } + + relPath := filepath.Join(modulesDirName, repoNameOnly) + if sourceDir != "" { + relPath = filepath.Join(relPath, sourceDir) + } + paths = append(paths, relPath) + } + + return paths +} + +// mergeDprojSearchPaths merges paths into the <DCC_UnitSearchPath> element of +// the project XML. It reports false when the element is missing, in which case +// the document must be left untouched. +// +// We do a simple string replacement instead of re-serialising the XML: it is +// extremely surgical and preserves the formatting written by the Delphi IDE. +// +// The result is deterministic: the entries already declared in the .dproj keep +// their relative order and come first, then the new ones in the order they were +// collected. Building the list out of a Go map instead reordered the search +// path on every run, which churned the versioned .dproj and -- worse -- kept +// changing which unit wins when two dependencies ship the same unit name, +// because the compiler resolves it by search path order. +func mergeDprojSearchPaths(xmlStr string, paths []string) (string, bool) { + const searchPathOpen = "<DCC_UnitSearchPath>" + const searchPathClose = "</DCC_UnitSearchPath>" + + startIndex := strings.Index(xmlStr, searchPathOpen) + if startIndex == -1 { + return "", false + } + endIndex := strings.Index(xmlStr[startIndex:], searchPathClose) + if endIndex == -1 { + return "", false + } + endIndex += startIndex + + seen := make(map[string]bool) + mergedPaths := make([]string, 0, len(paths)) + + appendPath := func(candidate string) { + if candidate == "" || seen[candidate] { + return + } + seen[candidate] = true + mergedPaths = append(mergedPaths, candidate) + } + + for _, p := range strings.Split(xmlStr[startIndex+len(searchPathOpen):endIndex], ";") { + appendPath(strings.TrimSpace(p)) + } + + for _, p := range paths { + // Normalise path separators to match Delphi (\) + appendPath(strings.ReplaceAll(p, "/", "\\")) + } + + newSearchPath := searchPathOpen + strings.Join(mergedPaths, ";") + searchPathClose + + return xmlStr[:startIndex] + newSearchPath + xmlStr[endIndex+len(searchPathClose):], true +} + +// runWorkspaceStatus checks git status of the repositories in the workspace. +// +// This is the report 'boss workspace status' has always printed, and it is +// still what an argument-less run without --json produces. The workspace id and +// the JSON payload are served by newWorkspaceStatusCmd, in workspace_status.go. +func runWorkspaceStatus(ctx context.Context) { + cwd, err := os.Getwd() + if err != nil { + msg.Die("โŒ Failed to get current directory: %s", err) + } + + // Find the root repo (it contains modules/) + dirs, err := os.ReadDir(cwd) + if err != nil { + msg.Die("โŒ Failed to list directory: %s", err) + } + + rootRepo := findWorkspaceRootRepo(cwd, dirs) + if rootRepo == "" { + // Flat topology check or fallback to list of git repos in cwd + msg.Info("No multi-repo workspace root found. Showing status for git repos in current directory:") + for _, d := range dirs { + if d.IsDir() && isGitRepo(filepath.Join(cwd, d.Name())) { + printRepoStatus(ctx, d.Name(), filepath.Join(cwd, d.Name())) + } + } + return + } + + msg.Info("Workspace Root: %s", rootRepo) + printRepoStatus(ctx, rootRepo+" (Root)", filepath.Join(cwd, rootRepo)) + + modulesPath := filepath.Join(cwd, rootRepo, modulesDirName) + moduleDirs, err := os.ReadDir(modulesPath) + if err != nil { + return + } + + for _, md := range moduleDirs { + if md.IsDir() && isGitRepo(filepath.Join(modulesPath, md.Name())) { + printRepoStatus(ctx, " โ””โ”€ "+md.Name(), filepath.Join(modulesPath, md.Name())) + } + } +} + +// findWorkspaceRootRepo returns the first directory of cwd that owns a modules +// directory, which is how a workspace root (PAI) repository is recognised. +func findWorkspaceRootRepo(cwd string, dirs []os.DirEntry) string { + for _, d := range dirs { + if !d.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(cwd, d.Name(), modulesDirName)); err == nil { + return d.Name() + } + } + + return "" +} + +// discoverWorkspaceRepos lists every git repository directly under cwd plus the +// ones nested in each candidate root's modules directory. +func discoverWorkspaceRepos(cwd string) []string { + dirs, err := os.ReadDir(cwd) + if err != nil { + msg.Die("โŒ Failed to list directory: %s", err) + } + + var repos []string + for _, d := range dirs { + if !d.IsDir() { + continue + } + + repoPath := filepath.Join(cwd, d.Name()) + if isGitRepo(repoPath) { + repos = append(repos, repoPath) + } + repos = append(repos, discoverModuleRepos(filepath.Join(repoPath, modulesDirName))...) + } + + return repos +} + +// discoverModuleRepos lists the git repositories inside a modules directory. +func discoverModuleRepos(modulesPath string) []string { + moduleDirs, err := os.ReadDir(modulesPath) + if err != nil { + return nil + } + + var repos []string + for _, md := range moduleDirs { + modulePath := filepath.Join(modulesPath, md.Name()) + if md.IsDir() && isGitRepo(modulePath) { + repos = append(repos, modulePath) + } + } + + return repos +} + +func printRepoStatus(ctx context.Context, label string, path string) { + // Current branch + branchOut, _ := gitCapture(ctx, path, "rev-parse", "--abbrev-ref", gitHeadRef) + branch := strings.TrimSpace(branchOut) + + // Dirty check + statusOut, _ := gitCapture(ctx, path, "status", "--porcelain") + isDirty := len(statusOut) > 0 + + // Ahead/Behind check + aheadBehind := "" + if abOut, ok := gitCapture(ctx, path, "rev-list", "--left-right", "--count", "HEAD...@{u}"); ok { + parts := strings.Fields(abOut) + if len(parts) == 2 { + ahead := parts[0] + behind := parts[1] + if ahead != "0" || behind != "0" { + aheadBehind = fmt.Sprintf(" (ahead %s, behind %s)", ahead, behind) + } + } + } + + statusStr := "clean" + if isDirty { + statusStr = "dirty" + } + + _, _ = fmt.Fprintf(os.Stdout, "%-35s [%s] branch: %s%s\n", label, statusStr, branch, aheadBehind) +} + +// runWorkspaceUpdate updates all repositories in the workspace. +// +// It shares its implementation with 'boss workspace pull': both fast-forward +// every repository of the workspace, and having two copies of that loop meant +// the two commands could drift apart. The shared version also tells a pinned +// (detached HEAD) repository apart from a repository that genuinely failed to +// pull, instead of reporting both as a warning. +func runWorkspaceUpdate(ctx context.Context) { + msg.Info("Updating workspace repositories (pulling changes)...") + + tally := pullWorkspaceRepos(ctx, requireWorkspaceRepos()) + + msg.Info("Update summary: %d updated, %d skipped, %d failed.", + tally.updated, tally.skipped, tally.failed) + + if tally.failed > 0 { + msg.Die("โŒ %d repository(ies) could not be fast-forwarded.", tally.failed) + } +} + +// runWorkspacePush pushes committed changes in all writable repositories in the workspace. +func runWorkspacePush(ctx context.Context) { + msg.Info("Pushing committed changes in workspace...") + cwd, err := os.Getwd() + if err != nil { + msg.Die("โŒ Failed to get current directory: %s", err) + } + + for _, repoPath := range discoverWorkspaceRepos(cwd) { + // Only push if ahead of remote + abOut, ok := gitCapture(ctx, repoPath, "rev-list", "--left-right", "--count", "HEAD...@{u}") + if !ok { + continue + } + + parts := strings.Fields(abOut) + if len(parts) != 2 || parts[0] == "0" { + continue + } + + msg.Info("Pushing changes in %s...", filepath.Base(repoPath)) + // #nosec G204 -- fixed git binary; repoPath is a directory found on disk + pushCmd := exec.CommandContext(ctx, "git", "-C", repoPath, "push") + pushCmd.Stdout = os.Stdout + pushCmd.Stderr = os.Stderr + if err := pushCmd.Run(); err != nil { + msg.Err("โŒ Failed to push changes in %s", filepath.Base(repoPath)) + } + } +} + +// runPkgSbom generates a CycloneDX or SPDX SBOM for a Delphi project. +func runPkgSbom(projectFile string, format string, outputDir string) { + // Any unknown value used to fall through to CycloneDX while the command + // announced the format the user asked for, so "--format spdxx" reported an + // SPDX run and wrote a CycloneDX document. Reject it instead. + normalizedFormat := strings.ToLower(strings.TrimSpace(format)) + if normalizedFormat != sbomFormatCycloneDx && normalizedFormat != sbomFormatSpdx { + msg.Die("โŒ Unsupported SBOM format %q. Supported formats: %s, %s.", + format, sbomFormatCycloneDx, sbomFormatSpdx) + } + + if projectFile == "" { + // Try to find a .dproj file in the current directory + files, err := filepath.Glob("*.dproj") + if err != nil || len(files) == 0 { + msg.Die("โŒ No Delphi project (.dproj) file specified, and none found in the current directory.") + } + projectFile = files[0] + } + + msg.Info("Generating %s SBOM for Delphi project: %s", strings.ToUpper(normalizedFormat), projectFile) + + // Since Boss already knows the dependencies of the project, we can generate a beautiful + // and highly conformant CycloneDX SBOM directly by parsing the project's boss.json and boss.lock! + // This is much faster, cleaner, and removes all DPM dependencies! + if _, err := os.Stat(bossManifestFile); os.IsNotExist(err) { + msg.Die("โŒ No boss.json manifest found. Cannot generate SBOM without package manifest.") + } + + data, err := os.ReadFile(bossManifestFile) + if err != nil { + msg.Die("โŒ Failed to read boss.json: %s", err) + } + + var manifest bossManifest + + if err := json.Unmarshal(data, &manifest); err != nil { + msg.Die("โŒ Failed to parse boss.json: %s", err) + } + + components := resolveSbomComponents(manifest) + + // Create output directory + if err := os.MkdirAll(outputDir, 0750); err != nil { + msg.Die("โŒ Failed to create output directory: %s", err) + } + + projectName := strings.TrimSuffix(filepath.Base(projectFile), ".dproj") + + if normalizedFormat == sbomFormatSpdx { + generateSpdxSbom(projectName, manifest, components, outputDir) + } else { + generateCycloneDxSbom(projectName, manifest, components, outputDir) + } +} + +// resolveSbomComponents turns the declared dependencies into SBOM components, +// preferring the exact versions recorded in the lock file. boss.json carries +// constraints ("^3.0.0"), which are not valid component versions in CycloneDX +// or SPDX, so the lock is the correct source -- the same reason cyclonedx-npm +// reads package-lock.json rather than package.json. +func resolveSbomComponents(manifest bossManifest) []sbomComponent { + locked := loadLockedVersions() + + names := make([]string, 0, len(manifest.Dependencies)) + for name := range manifest.Dependencies { + names = append(names, name) + } + // Deterministic output: iterating the map directly would reorder the SBOM + // on every run and churn the file in version control. + sort.Strings(names) + + unresolved := 0 + components := make([]sbomComponent, 0, len(names)) + for _, name := range names { + component := sbomComponent{Name: name, Constraint: manifest.Dependencies[name]} + if dep, ok := locked[normalizeDepKey(name)]; ok && dep.Version != "" { + component.Version = dep.Version + component.Hash = dep.Hash + component.Resolved = true + } else { + // Version stays empty on purpose. A constraint is not a version, + // and stating one as if it were is exactly what makes an SBOM + // dangerous: a scanner would read it as the version in the tree. + unresolved++ + } + component.Purl = buildPurl(name, component.Version) + components = append(components, component) + } + + if unresolved > 0 { + msg.Warn(" %d dependency(ies) not found in the lock file and are reported without a version. "+ + "Run 'boss install' so the SBOM can state exact versions.", unresolved) + } + + return components +} + +// loadLockedVersions reads the lock file, keyed by normalized dependency name. +// A missing or unreadable lock is not fatal: the SBOM still gets built from +// boss.json, with a warning emitted by the caller. +func loadLockedVersions() map[string]domain.LockedDependency { + result := make(map[string]domain.LockedDependency) + + lockRepo := repository.NewFileLockRepository(filesystem.NewOSFileSystem()) + lock, err := lockRepo.Load(consts.FilePackageLock) + if err != nil || lock == nil { + return result + } + + for key, dep := range lock.Installed { + result[normalizeDepKey(key)] = dep + } + + return result +} + +// normalizeDepKey reduces a boss.json dependency name ("hashload/horse") and a +// lock key (the lowercased repository URL) to the same "owner/repo" form. +// The host is deliberately preserved. Dropping it made +// "github.com/acme/lib" and "gitlab.com/acme/lib" collide on a single key, +// so one dependency's locked version was reported for the other -- and the +// winner depended on Go map iteration order, changing between runs. +func normalizeDepKey(value string) string { + key := strings.ToLower(strings.TrimSpace(value)) + key = strings.TrimPrefix(key, "https://") + key = strings.TrimPrefix(key, "http://") + if idx := strings.Index(key, "@"); idx != -1 { + key = key[idx+1:] + } + key = strings.ReplaceAll(key, ":", "/") + key = strings.TrimSuffix(strings.TrimSuffix(key, "/"), ".git") + + return strings.Trim(key, "/") +} + +// splitRepoRef breaks a dependency reference into host, owner and repository. +// A reference without a host defaults to github.com, which is where Boss +// dependencies are resolved from when the manifest omits it. +func splitRepoRef(name string) (string, string, string) { + parts := strings.Split(normalizeDepKey(name), "/") + switch len(parts) { + case 0: + return "", "", "" + case 1: + return defaultForgeHost, "", parts[0] + case 2: + return defaultForgeHost, parts[0], parts[1] + default: + return parts[0], parts[len(parts)-2], parts[len(parts)-1] + } +} + +// buildPurl emits a package URL for the dependency. Boss dependencies are Git +// repositories, so pkg:github is the correct type -- "pkg:delphi" is not a +// registered purl type and would be rejected by conformant consumers. +// See https://github.com/package-url/purl-spec +// buildPurl emits a package URL for a dependency. +// +// Only an exact version is appended: purl requires a percent-encoded version +// and the github type documents it as "a commit or tag", so a boss.json +// constraint such as "^3.0.0" is not a version and is left out entirely. +// See https://github.com/package-url/purl-spec. +// +// pkg:github requires a namespace, and there is no registered purl type for +// other forges, so anything that is not github.com with an owner falls back +// to pkg:generic carrying a vcs_url qualifier. Emitting pkg:github for a +// GitLab dependency pointed at a different -- possibly unrelated -- project. +func buildPurl(name, version string) string { + host, owner, repo := splitRepoRef(name) + if repo == "" { + return "" + } + + suffix := "" + if version != "" { + suffix = "@" + url.PathEscape(version) + } + + if host == defaultForgeHost && owner != "" { + return "pkg:github/" + owner + "/" + repo + suffix + } + + vcs := "git+https://" + host + "/" + if owner != "" { + vcs += owner + "/" + } + vcs += repo + + return "pkg:generic/" + repo + suffix + "?vcs_url=" + url.QueryEscape(vcs) +} + +func generateCycloneDxSbom(projectName string, manifest bossManifest, components []sbomComponent, outputDir string) { + // A standard, conformant CycloneDX v1.5 JSON schema + // We read the fields and build the CycloneDX struct + type Property struct { + Name string `json:"name"` + Value string `json:"value"` + } + + type Component struct { + Type string `json:"type"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + Purl string `json:"purl"` + Properties []Property `json:"properties,omitempty"` + } + + type Metadata struct { + Timestamp string `json:"timestamp"` + Component Component `json:"component"` + } + + type CycloneDX struct { + BomFormat string `json:"bomFormat"` + SpecVersion string `json:"specVersion"` + SerialNumber string `json:"serialNumber"` + Version int `json:"version"` + Metadata Metadata `json:"metadata"` + Components []Component `json:"components"` + } + + mName := manifest.Name + if mName == "" { + mName = "my-delphi-app" + } + mVersion := manifest.Version + if mVersion == "" { + mVersion = defaultPackageVersion + } + mDesc := manifest.Description + + cdx := CycloneDX{ + BomFormat: "CycloneDX", + SpecVersion: "1.5", + SerialNumber: "urn:uuid:" + generateUUID(), + Version: 1, + Metadata: Metadata{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + Component: Component{ + Type: "application", + Name: mName, + Version: mVersion, + Description: mDesc, + Purl: buildPurl(mName, mVersion), + }, + }, + Components: []Component{}, + } + + for _, dep := range components { + // Deliberately no "hashes" entry: the digest boss stores in the lock is + // a directory-change fingerprint (utils.HashDir), not a cryptographic + // hash of a distributed artifact, so it must not be presented as one. + properties := []Property{ + {Name: "boss:resolved", Value: strconv.FormatBool(dep.Resolved)}, + } + if dep.Constraint != "" { + properties = append(properties, Property{Name: "boss:constraint", Value: dep.Constraint}) + } + + cdx.Components = append(cdx.Components, Component{ + Type: "library", + Name: dep.Name, + Version: dep.Version, + Purl: dep.Purl, + Properties: properties, + }) + } + + outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.cdx.json", projectName)) + data, err := json.MarshalIndent(cdx, "", " ") + if err != nil { + msg.Die("โŒ Failed to marshal CycloneDX JSON: %s", err) + } + + if err := os.WriteFile(outputFile, data, 0600); err != nil { + msg.Die("โŒ Failed to write CycloneDX SBOM: %s", err) + } + + msg.Info(" SBOM successfully generated: %s", outputFile) +} + +func generateSpdxSbom(projectName string, manifest bossManifest, components []sbomComponent, outputDir string) { + outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.spdx", projectName)) + mName := manifest.Name + if mName == "" { + mName = "my-delphi-app" + } + mVersion := manifest.Version + if mVersion == "" { + mVersion = defaultPackageVersion + } + + // Simple SPDX format writer + var buf bytes.Buffer + buf.WriteString("SPDXVersion: SPDX-2.3\n") + buf.WriteString("DataLicense: CC0-1.0\n") + buf.WriteString("SPDXID: SPDXRef-DOCUMENT\n") + fmt.Fprintf(&buf, "DocumentName: %s-SBOM\n", projectName) + buf.WriteString("DocumentNamespace: https://www.pubpascal.dev/spdx/" + projectName + "-" + generateUUID() + "\n") + buf.WriteString("Creator: Tool: Boss-PubPascal\n") + fmt.Fprintf(&buf, "Created: %s\n\n", time.Now().UTC().Format(time.RFC3339)) + + fmt.Fprintf(&buf, "PackageName: %s\n", mName) + buf.WriteString("SPDXID: SPDXRef-Package-Root\n") + fmt.Fprintf(&buf, "PackageVersion: %s\n", mVersion) + buf.WriteString("PackageDownloadLocation: NOASSERTION\n") + buf.WriteString("FilesAnalyzed: false\n") + buf.WriteString("PackageLicenseConcluded: NOASSERTION\n") + buf.WriteString("PackageLicenseDeclared: NOASSERTION\n\n") + + for i, dep := range components { + depRef := fmt.Sprintf("SPDXRef-Package-Dep-%d", i+1) + fmt.Fprintf(&buf, "PackageName: %s\n", dep.Name) + fmt.Fprintf(&buf, "SPDXID: %s\n", depRef) + fmt.Fprintf(&buf, "PackageVersion: %s\n", dep.Version) + buf.WriteString("PackageDownloadLocation: NOASSERTION\n") + buf.WriteString("FilesAnalyzed: false\n") + buf.WriteString("PackageLicenseConcluded: NOASSERTION\n") + buf.WriteString("PackageLicenseDeclared: NOASSERTION\n") + fmt.Fprintf(&buf, "ExternalRef: PACKAGE-MANAGER purl %s\n", dep.Purl) + fmt.Fprintf(&buf, "Relationship: SPDXRef-Package-Root DEPENDS_ON %s\n\n", depRef) + } + + if err := os.WriteFile(outputFile, buf.Bytes(), 0600); err != nil { + msg.Die("โŒ Failed to write SPDX SBOM: %s", err) + } + + msg.Info(" SBOM successfully generated: %s", outputFile) +} + +// generateUUID returns a random RFC 4122 version 4 UUID. +// +// CycloneDX constrains serialNumber to +// ^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ +// (https://cyclonedx.org/docs/1.5/json/), so the value has to be a real UUID: +// the previous timestamp-derived version overflowed the final group past 12 +// hex digits and put the version nibble in the variant position, which made +// every generated document fail schema validation. +func generateUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + msg.Die("โŒ Failed to generate UUID: %s", err) + } + + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant RFC 4122 + + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +// runPkgSpec scaffolds a starter pubpascal.json manifest file. +func runPkgSpec(id string, version string) { + if id == "" { + msg.Die("โŒ Parameter --id is required to scaffold a manifest.") + } + + manifest := map[string]interface{}{ + "name": id, + "version": version, + "description": "Starter Delphi package manifest", + "sources": sourceDirName, + "dependencies": map[string]string{}, + } + + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + msg.Die("โŒ Failed to marshal manifest JSON: %s", err) + } + + fileName := "pubpascal.json" + if err := os.WriteFile(fileName, data, 0600); err != nil { + msg.Die("โŒ Failed to write manifest file: %s", err) + } + + msg.Info("Scaffolded starter manifest in %s", fileName) +} + +// runPortalLogin handles the PubPascal portal login flow and saves the token. +// +// The caller only routes here when --token carries a value, so there is no +// positional fallback to read: 'boss login <repo>' is the git registry flow. +func runPortalLogin(token string) { + // The portal decides whether a token is valid. Enforcing a prefix here + // would break every existing client the day the portal changes its token + // format, so only the empty case is rejected locally. + if token == "" { + msg.Die("โŒ Error: missing token. Pass it with 'boss login --token <token>'.") + } + + config, err := LoadPubPascalConfig() + if err != nil { + msg.Die("โŒ Failed to load PubPascal config: %s", err) + } + + config.AuthToken = token + if err := SavePubPascalConfig(config); err != nil { + msg.Die("โŒ Failed to save token to config: %s", err) + } + + msg.Info("Login successful. Token saved to config.") +} diff --git a/internal/adapters/primary/cli/root.go b/internal/adapters/primary/cli/root.go index afb6d28..1d0a301 100644 --- a/internal/adapters/primary/cli/root.go +++ b/internal/adapters/primary/cli/root.go @@ -19,6 +19,29 @@ const ( appDescription = "Dependency Manager for Delphi" ) +// Command names, shared between each command registration and the grouping +// pass in applyCommandGroups, which matches commands by name. +const ( + cmdNameNew = "new" + cmdNameRun = "run" + cmdNameLogin = "login" + cmdNameWorkspace = "workspace" + cmdNameContribute = "contribute" + cmdNameCRA = "cra" + cmdNameVersion = "version" +) + +// Identifiers of the help groups printed by 'boss --help'. +const ( + groupIDLegacy = "legacy" + groupIDProject = "new" + groupIDPubPascal = "pubpascal" + groupIDCRA = "cra" +) + +// flagNameVersion is the long form of the --version flag. +const flagNameVersion = "version" + // Execute executes the root command. func Execute() error { var versionPrint bool @@ -49,10 +72,54 @@ func Execute() error { root.PersistentFlags().BoolVarP(&global, "global", "g", false, "global environment") root.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "debug") - root.Flags().BoolVarP(&versionPrint, "version", "v", false, "show cli version") + root.Flags().BoolVarP(&versionPrint, flagNameVersion, "v", false, "show cli version") - setup.Initialize() + isHelpOrVersion := isHelpOrVersionInvocation(os.Args) + + if isHelpOrVersion { + setup.InitializeMinimal() + } else { + setup.Initialize() + } + registerCommands(root) + applyCommandGroups(root) + + if !isHelpOrVersion { + if err := gc.RunGC(false); err != nil { + return err + } + } + + if err := root.Execute(); err != nil { + os.Exit(1) + } + + return nil +} + +// isHelpOrVersionInvocation reports whether boss was called only to print help +// or the version, in which case the full environment setup can be skipped. +// +// Only the first argument is inspected. Scanning every argument also matched +// the flags of sub-commands -- "boss dependencies -v" asks for the version of +// each dependency, not for the version of the CLI -- and those runs silently +// skipped setup.Initialize, and with it the migrations and InitializePath. +func isHelpOrVersionInvocation(args []string) bool { + if len(args) <= 1 { + return true + } + + switch args[1] { + case "help", "-h", "--help", cmdNameVersion, "-v", "--version": + return true + default: + return false + } +} + +// registerCommands wires every boss command into the root command. +func registerCommands(root *cobra.Command) { config.RegisterConfigCommand(root) initCmdRegister(root) newCmdRegister(root) @@ -64,15 +131,36 @@ func Execute() error { upgradeCmdRegister(root) dependenciesCmdRegister(root) versionCmdRegister(root) + pubpascalCmdRegister(root) + craCmdRegister(root) + contributeCmdRegister(root) - if err := gc.RunGC(false); err != nil { - return err - } - + // Registered before the grouping pass in applyCommandGroups: any command + // added afterwards keeps an empty GroupID and cobra prints it in a stray + // "Additional Commands" block instead of one of the groups. config.RegisterCmd(root) - if err := root.Execute(); err != nil { - os.Exit(1) - } +} - return nil +// applyCommandGroups declares the help groups and assigns every registered +// command to one of them. +func applyCommandGroups(root *cobra.Command) { + root.AddGroup( + &cobra.Group{ID: groupIDLegacy, Title: "Available Commands:"}, + &cobra.Group{ID: groupIDProject, Title: "Project & Packaging:"}, + &cobra.Group{ID: groupIDPubPascal, Title: "PubPascal Portal:"}, + &cobra.Group{ID: groupIDCRA, Title: "Cyber Resilience Act (CRA) & SBOM:"}, + ) + + for _, cmd := range root.Commands() { + switch cmd.Name() { + case cmdNameNew, projectTypePkg, cmdNameRun: + cmd.GroupID = groupIDProject + case cmdNameLogin, cmdNameWorkspace, cmdNameContribute: + cmd.GroupID = groupIDPubPascal + case cmdNameCRA, sbomBaseName: + cmd.GroupID = groupIDCRA + default: + cmd.GroupID = groupIDLegacy + } + } } diff --git a/internal/adapters/primary/cli/workspace_git.go b/internal/adapters/primary/cli/workspace_git.go new file mode 100644 index 0000000..6dc46fa --- /dev/null +++ b/internal/adapters/primary/cli/workspace_git.go @@ -0,0 +1,378 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hashload/boss/pkg/msg" + "github.com/spf13/cobra" +) + +// Names of the workspace sub-commands that only drive git. +const ( + subCmdNameDiff = "diff" + subCmdNamePull = "pull" + subCmdNameCommit = "commit" +) + +// excludeModulesPathspec keeps a git command inside the repository's own files. +// +// A workspace root holds its dependency repositories under modules/, and each +// of those is a git repository of its own that discoverWorkspaceRepos already +// reports separately. Without this pathspec, 'git status' in the root would +// flag the whole modules/ tree as an untracked change forever, and 'git add -A' +// would stage the nested clones as gitlinks -- committing a bogus submodule +// reference into the PAI repository. +const excludeModulesPathspec = ":(exclude)" + modulesDirName + +// gitHeadRef is the symbolic name of the current checkout. It is also what +// 'git rev-parse --abbrev-ref HEAD' echoes back when the checkout is detached, +// which is how a repository pinned to a tag or a commit is recognised. +const gitHeadRef = "HEAD" + +// workspaceDiffPayload is the contract consumed by the PubPascal desktop app, +// which renders one collapsible block per repository. +type workspaceDiffPayload struct { + Repos []workspaceRepoDiff `json:"repos"` +} + +// workspaceRepoDiff is the uncommitted diff of a single workspace repository. +type workspaceRepoDiff struct { + Name string `json:"name"` + Diff string `json:"diff"` +} + +// pullOutcome is the result of fast-forwarding a single repository. +type pullOutcome int + +const ( + pullUpdated pullOutcome = iota + pullSkipped + pullFailed +) + +// pullTally counts the outcomes of a whole workspace pull. +type pullTally struct { + updated int + skipped int + failed int +} + +// newWorkspaceDiffCmd builds 'boss workspace diff'. +func newWorkspaceDiffCmd() *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: subCmdNameDiff, + Short: "Show the uncommitted changes of every repository in the workspace", + Long: "Show the uncommitted changes of every repository in the workspace.\n\n" + + "With --json the result is printed on standard output as an object holding " + + "a \"repos\" array of name/diff entries. Only repositories that actually " + + "have changes are listed; a repository whose only change is an untracked " + + "file appears with an empty diff, because an untracked file has nothing to " + + "compare against HEAD.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + runWorkspaceDiff(cmd.Context(), asJSON) + }, + } + + cmd.Flags().BoolVar(&asJSON, flagNameJSON, false, "print the diff as JSON on standard output") + + return cmd +} + +// newWorkspacePullCmd builds 'boss workspace pull'. +func newWorkspacePullCmd() *cobra.Command { + return &cobra.Command{ + Use: subCmdNamePull, + Short: "Fast-forward every repository in the workspace", + Long: "Fast-forward every repository in the workspace.\n\n" + + "Repositories pinned to a tag or commit, and branches that track no " + + "remote, are reported as skipped rather than failed: there is nothing to " + + "fast-forward. Any other failure makes the command exit non-zero.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + runWorkspacePull(cmd.Context()) + }, + } +} + +// newWorkspaceCommitCmd builds 'boss workspace commit'. +func newWorkspaceCommitCmd() *cobra.Command { + var message string + + cmd := &cobra.Command{ + Use: subCmdNameCommit, + Short: "Commit the pending changes of every modified repository in the workspace", + Long: "Commit the pending changes of every modified repository in the workspace.\n\n" + + "Each repository stages its own files -- the nested modules/ clones are " + + "left alone -- and commits them with the given message. Nothing is pushed: " + + "run 'boss workspace push' for that.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + runWorkspaceCommit(cmd.Context(), message) + }, + } + + cmd.Flags().StringVarP(&message, "message", "m", "", "commit message (required)") + + return cmd +} + +// requireWorkspaceRepos returns the git repositories of the workspace rooted at +// the current directory, refusing to report success when there are none. +// +// discoverWorkspaceRepos returns an empty slice both for "the workspace is +// clean" and for "you are in the wrong folder", and the second case used to be +// indistinguishable from a no-op success. +func requireWorkspaceRepos() []string { + cwd, err := os.Getwd() + if err != nil { + msg.Die("โŒ Failed to get current directory: %s", flattenDetail(err.Error())) + } + + repos := discoverWorkspaceRepos(cwd) + if len(repos) == 0 { + msg.Die("โŒ No git repository found under %s. "+ + "Run this from the folder where the workspace was cloned.", cwd) + } + + return repos +} + +// workspaceRepoStatus returns the porcelain status of a repository's own files. +func workspaceRepoStatus(ctx context.Context, repoPath string) (string, bool) { + out, ok := gitCapture(ctx, repoPath, "status", "--porcelain", "--", ".", excludeModulesPathspec) + + return strings.TrimSpace(out), ok +} + +// runWorkspaceDiff collects the uncommitted changes of the whole workspace. +func runWorkspaceDiff(ctx context.Context, asJSON bool) { + repos := requireWorkspaceRepos() + + payload := workspaceDiffPayload{Repos: make([]workspaceRepoDiff, 0, len(repos))} + for _, repoPath := range repos { + status, ok := workspaceRepoStatus(ctx, repoPath) + if !ok { + msg.Warn(" %s: could not be read as a git repository, skipping.", filepath.Base(repoPath)) + + continue + } + if status == "" { + continue + } + + // A diff against HEAD covers staged and unstaged edits to tracked + // files. Untracked files never appear in one, so a repository whose + // only change is a new file is still listed, with an empty diff. + diff, _ := gitCapture(ctx, repoPath, "diff", gitHeadRef, "--", ".", excludeModulesPathspec) + payload.Repos = append(payload.Repos, workspaceRepoDiff{ + Name: filepath.Base(repoPath), + Diff: diff, + }) + } + + if asJSON { + printJSONPayload(payload) + + return + } + + printWorkspaceDiffText(payload) +} + +// printWorkspaceDiffText prints the human-readable diff report. +func printWorkspaceDiffText(payload workspaceDiffPayload) { + if len(payload.Repos) == 0 { + msg.Info("Nothing to diff: every repository in the workspace is clean.") + + return + } + + for _, repo := range payload.Repos { + msg.Info("=== %s ===", repo.Name) + if strings.TrimSpace(repo.Diff) == "" { + msg.Info(" (untracked changes only, nothing to diff against HEAD)") + + continue + } + // Written straight to stdout: a diff is full of '%' and would be + // mangled by a format-string logger. + _, _ = fmt.Fprintln(os.Stdout, repo.Diff) + } +} + +// runWorkspacePull fast-forwards every repository and fails loudly if any real +// pull failed. Skipped repositories do not make the command fail. +func runWorkspacePull(ctx context.Context) { + msg.Info("Pulling every repository in the workspace...") + + tally := pullWorkspaceRepos(ctx, requireWorkspaceRepos()) + + msg.Info("Pull summary: %d updated, %d skipped, %d failed.", + tally.updated, tally.skipped, tally.failed) + + if tally.failed > 0 { + msg.Die("โŒ %d repository(ies) could not be fast-forwarded.", tally.failed) + } +} + +// pullWorkspaceRepos fast-forwards each repository and tallies the outcomes. +func pullWorkspaceRepos(ctx context.Context, repos []string) pullTally { + var tally pullTally + + for _, repoPath := range repos { + switch pullWorkspaceRepo(ctx, repoPath) { + case pullUpdated: + tally.updated++ + case pullSkipped: + tally.skipped++ + case pullFailed: + tally.failed++ + } + } + + return tally +} + +// pullWorkspaceRepo fast-forwards a single repository. +// +// Two situations are skips rather than failures, because a workspace is +// expected to contain them: a repository checked out at a pinned tag or commit +// has a detached HEAD and no branch to advance, and a local-only branch has no +// upstream to pull from. Calling either one a failure would paint a correctly +// pinned workspace red on every run. +func pullWorkspaceRepo(ctx context.Context, repoPath string) pullOutcome { + name := filepath.Base(repoPath) + + branchOut, ok := gitCapture(ctx, repoPath, "rev-parse", "--abbrev-ref", gitHeadRef) + if !ok { + msg.Err(" %s: could not be read as a git repository.", name) + + return pullFailed + } + + branch := strings.TrimSpace(branchOut) + if branch == gitHeadRef || branch == "" { + msg.Info(" %s: skipped, detached HEAD (pinned to a fixed reference).", name) + + return pullSkipped + } + + if _, hasUpstream := gitCapture(ctx, repoPath, "rev-parse", "--abbrev-ref", + "--symbolic-full-name", "@{u}"); !hasUpstream { + msg.Info(" %s: skipped, branch %s tracks no remote.", name, branch) + + return pullSkipped + } + + msg.Info(" %s: fast-forwarding %s...", name, branch) + if _, err := runGitCmd(ctx, repoPath, "pull", "--ff-only"); err != nil { + msg.Err(" %s: git pull failed: %s", name, flattenDetail(err.Error())) + + return pullFailed + } + + return pullUpdated +} + +// runWorkspaceCommit commits the pending changes of every modified repository. +// +// Nothing is pushed: 'boss workspace push' is the command that publishes, and +// committing and pushing in one step would take the decision away from whoever +// is still reviewing the change. +func runWorkspaceCommit(ctx context.Context, message string) { + message = strings.TrimSpace(message) + if message == "" { + msg.Die("โŒ A commit message is required. Run 'boss workspace commit -m \"your message\"'.") + } + + repos := requireWorkspaceRepos() + + committed := 0 + pinned := 0 + failed := 0 + for _, repoPath := range repos { + switch commitWorkspaceRepo(ctx, repoPath, message) { + case commitDone: + committed++ + case commitClean: + case commitPinned: + pinned++ + case commitFailed: + failed++ + } + } + + msg.Info("Commit summary: %d committed, %d pinned and left alone, %d failed.", + committed, pinned, failed) + + if failed > 0 { + msg.Die("โŒ %d repository(ies) could not be committed.", failed) + } + if committed == 0 && pinned > 0 { + msg.Die("โŒ Nothing was committed: the repositories with changes are all pinned.") + } + if committed == 0 && pinned == 0 { + msg.Info("Nothing to commit: every repository in the workspace is clean.") + } +} + +// commitOutcome is the result of committing a single repository. +type commitOutcome int + +const ( + commitDone commitOutcome = iota + commitClean + commitPinned + commitFailed +) + +// commitWorkspaceRepo stages and commits one repository's own changes. +func commitWorkspaceRepo(ctx context.Context, repoPath string, message string) commitOutcome { + name := filepath.Base(repoPath) + + status, ok := workspaceRepoStatus(ctx, repoPath) + if !ok { + msg.Err(" %s: could not be read as a git repository.", name) + + return commitFailed + } + if status == "" { + return commitClean + } + + // A repository pinned to a tag or a commit sits on a detached HEAD. + // Committing there succeeds and then strands the commit: no branch points + // at it, 'boss workspace push' has nothing to push, and the next checkout + // drops it. Editing a pinned dependency is what 'boss contribute' is for. + if branch, branchOK := gitCapture(ctx, repoPath, "rev-parse", "--abbrev-ref", gitHeadRef); branchOK { + if trimmed := strings.TrimSpace(branch); trimmed == gitHeadRef || trimmed == "" { + msg.Warn(" %s: has changes but is pinned to a fixed reference (detached HEAD), "+ + "so nothing was committed. Your edits are untouched in the working tree. "+ + "Run 'boss contribute' to turn them into a pull request.", name) + + return commitPinned + } + } + + msg.Info(" %s: staging and committing...", name) + if _, err := runGitCmd(ctx, repoPath, "add", "-A", "--", ".", excludeModulesPathspec); err != nil { + msg.Err(" %s: git add failed: %s", name, flattenDetail(err.Error())) + + return commitFailed + } + + if _, err := runGitCmd(ctx, repoPath, "commit", "-m", message); err != nil { + msg.Err(" %s: git commit failed: %s", name, flattenDetail(err.Error())) + + return commitFailed + } + + return commitDone +} diff --git a/internal/adapters/primary/cli/workspace_ops_test.go b/internal/adapters/primary/cli/workspace_ops_test.go new file mode 100644 index 0000000..78c36d7 --- /dev/null +++ b/internal/adapters/primary/cli/workspace_ops_test.go @@ -0,0 +1,203 @@ +//nolint:testpackage // exercises unexported command plumbing +package cli + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestFlattenDetailRemovesBraces guards the rule that lets the PubPascal host +// tell a payload from an error: the host slices the captured output from the +// first brace to the last one, so an error detail carrying braces would be +// parsed as if it were the command's result. +func TestFlattenDetailRemovesBraces(t *testing.T) { + got := flattenDetail("boom {\"error\":\"nope\"}\nsecond line") + if strings.ContainsAny(got, "{}") { + t.Errorf("flattenDetail kept a brace: %q", got) + } + if strings.ContainsAny(got, "\r\n") { + t.Errorf("flattenDetail kept a newline: %q", got) + } +} + +// TestFlattenDetailTruncatesOnRuneBoundary checks the cap does not split a +// multi-byte rune, which would emit an invalid UTF-8 byte to the host. +func TestFlattenDetailTruncatesOnRuneBoundary(t *testing.T) { + got := flattenDetail(strings.Repeat("รง", maxErrorDetailRunes+50)) + if !strings.HasSuffix(got, "...") { + t.Fatalf("expected a truncated detail, got %q", got) + } + if !strings.HasPrefix(got, strings.Repeat("รง", maxErrorDetailRunes)) { + t.Error("truncation did not happen on a rune boundary") + } +} + +// TestPortalEndpointJoinsWithoutDoubleSlash covers a base URL saved with a +// trailing slash, which would otherwise produce //api/workspaces. +func TestPortalEndpointJoinsWithoutDoubleSlash(t *testing.T) { + for _, base := range []string{"https://www.pubpascal.dev", "https://www.pubpascal.dev/"} { + got := portalEndpoint(&PubPascalConfig{PortalBaseURL: base}, "/api/workspaces") + if got != "https://www.pubpascal.dev/api/workspaces" { + t.Errorf("base %q produced %q", base, got) + } + } +} + +// TestWorkspaceListPayloadPreservesUnknownFields proves the pass-through: the +// desktop cockpit reads fields off each entry that this CLI never names, so +// decoding through a narrower struct would silently drop them. +func TestWorkspaceListPayloadPreservesUnknownFields(t *testing.T) { + body := []byte(`{"workspaces":[{"id":"abc","name":"Janus","extra":42}]}`) + + var payload workspaceListPayload + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + out, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != string(body) { + t.Errorf("round trip changed the payload:\n got %s\nwant %s", out, body) + } +} + +// TestCatalogPayloadPreservesUnknownFields is the same guarantee for the +// catalog, whose entries carry slug/tier/score/stars/downloads. +func TestCatalogPayloadPreservesUnknownFields(t *testing.T) { + body := []byte(`{"packages":[{"slug":"janus","name":"Janus","tier":"gold","score":95}]}`) + + var payload catalogPayload + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + out, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != string(body) { + t.Errorf("round trip changed the payload:\n got %s\nwant %s", out, body) + } +} + +// TestEmptyPayloadsMarshalAsArrays checks that "nothing found" is an empty +// array and not null: the desktop app calls .length on it. +func TestEmptyPayloadsMarshalAsArrays(t *testing.T) { + cases := map[string]any{ + `{"workspaces":[]}`: workspaceListPayload{Workspaces: []json.RawMessage{}}, + `{"packages":[]}`: catalogPayload{Packages: []json.RawMessage{}}, + `{"repos":[]}`: workspaceDiffPayload{Repos: []workspaceRepoDiff{}}, + } + + for want, payload := range cases { + out, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal %s: %v", want, err) + } + if string(out) != want { + t.Errorf("got %s, want %s", out, want) + } + } +} + +// TestWorkspaceDiffPayloadShape pins the field names the desktop overlay reads. +func TestWorkspaceDiffPayloadShape(t *testing.T) { + out, err := json.Marshal(workspaceDiffPayload{ + Repos: []workspaceRepoDiff{{Name: "janus", Diff: "@@ -1 +1 @@\n-a\n+b\n"}}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const want = `{"repos":[{"name":"janus","diff":"@@ -1 +1 @@\n-a\n+b\n"}]}` + if string(out) != want { + t.Errorf("got %s, want %s", out, want) + } +} + +// TestGetPortalJSONMapsStatusCodes checks that every failure the portal can +// return becomes a distinct, non-empty error instead of an empty payload. +func TestGetPortalJSONMapsStatusCodes(t *testing.T) { + cases := []struct { + name string + status int + body string + want error + }{ + {"unauthorized", http.StatusUnauthorized, `{"error":"nope"}`, errPortalUnauthorized}, + {"forbidden", http.StatusForbidden, `{"error":"nope"}`, errPortalUnauthorized}, + {"rate limited", http.StatusTooManyRequests, `{"error":"Too many requests"}`, errPortalRateLimited}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + _, err := getPortalJSON(t.Context(), server.URL, "token") + if !errors.Is(err, tc.want) { + t.Errorf("got error %v, want %v", err, tc.want) + } + }) + } +} + +// TestGetPortalJSONServerErrorDetailIsBraceFree makes sure a hostile or broken +// response body cannot smuggle a JSON object into the error message. +func TestGetPortalJSONServerErrorDetailIsBraceFree(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("<html><style>body{color:red}</style></html>")) + })) + defer server.Close() + + _, err := getPortalJSON(t.Context(), server.URL, "") + if err == nil { + t.Fatal("expected an error for HTTP 502") + } + if strings.ContainsAny(err.Error(), "{}") { + t.Errorf("error detail leaked a brace: %q", err) + } +} + +// TestGetPortalJSONSendsBearerOnlyWhenGiven covers the split between the +// authenticated workspace list and the public, login-free package catalog. +func TestGetPortalJSONSendsBearerOnlyWhenGiven(t *testing.T) { + for _, token := range []string{"", "secret-token"} { + var seen string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"packages":[]}`)) + })) + + if _, err := getPortalJSON(t.Context(), server.URL, token); err != nil { + t.Fatalf("token %q: %v", token, err) + } + server.Close() + + want := "" + if token != "" { + want = "Bearer " + token + } + if seen != want { + t.Errorf("token %q sent Authorization %q, want %q", token, seen, want) + } + } +} + +// TestExcludeModulesPathspecTargetsModulesDir keeps the guard tied to the +// directory the workspace clone actually uses for dependencies. +func TestExcludeModulesPathspecTargetsModulesDir(t *testing.T) { + if want := ":(exclude)" + modulesDirName; excludeModulesPathspec != want { + t.Errorf("got %q, want %q", excludeModulesPathspec, want) + } +} diff --git a/internal/adapters/primary/cli/workspace_portal.go b/internal/adapters/primary/cli/workspace_portal.go new file mode 100644 index 0000000..15fe04b --- /dev/null +++ b/internal/adapters/primary/cli/workspace_portal.go @@ -0,0 +1,272 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/hashload/boss/pkg/msg" + "github.com/spf13/cobra" +) + +// Names of the workspace sub-commands backed by the PubPascal portal. +const ( + subCmdNameList = "list" + subCmdNameSearch = "search" +) + +// flagNameJSON switches a sub-command from its human-readable report to the +// machine payload consumed by the PubPascal desktop app and the RAD Studio +// (OTA) plugin. Both spawn this CLI and parse its standard output. +const flagNameJSON = "json" + +// maxErrorDetailRunes caps how much of a portal or git error is echoed back. +const maxErrorDetailRunes = 200 + +// workspaceListPayload is the response of GET /api/workspaces. +// +// The entries are kept as raw JSON so every field the portal sends survives the +// round trip untouched: this command is a pass-through, and re-encoding through +// a narrower struct would silently drop any field added to the API later. +type workspaceListPayload struct { + Workspaces []json.RawMessage `json:"workspaces"` +} + +// catalogPayload is the response of GET /api/packages/catalog, kept raw for the +// same reason as workspaceListPayload -- the desktop cockpit reads slug, name, +// description, tier, score, stars, downloads and repository_url off each entry. +type catalogPayload struct { + Packages []json.RawMessage `json:"packages"` +} + +// workspaceSummary is the subset of a workspace entry the text report prints. +type workspaceSummary struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// Portal failures that carry no detail beyond the status code itself. +var ( + errPortalUnauthorized = errors.New("unauthorized, your portal auth token is invalid or expired") + errPortalRateLimited = errors.New("rate limited by the portal, try again in a moment") +) + +// newWorkspaceListCmd builds 'boss workspace list'. +func newWorkspaceListCmd() *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: subCmdNameList, + Short: "List the workspaces you own on the PubPascal portal", + Long: "List the workspaces you own on the PubPascal portal.\n\n" + + "With --json the portal payload is echoed verbatim on standard output, as " + + "an object holding a \"workspaces\" array of id/name entries.", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, _ []string) { + runWorkspaceList(cmd.Context(), asJSON) + }, + } + + cmd.Flags().BoolVar(&asJSON, flagNameJSON, false, "print the portal payload as JSON on standard output") + + return cmd +} + +// newWorkspaceSearchCmd builds 'boss workspace search'. +func newWorkspaceSearchCmd() *cobra.Command { + return &cobra.Command{ + Use: subCmdNameSearch + " [query]", + Short: "Search the public PubPascal package catalog", + Long: "Search the public PubPascal package catalog.\n\n" + + "The catalog is public, so no login is required. Without a query the portal " + + "returns its curated front page. The result is always printed on standard " + + "output as an object holding a \"packages\" array.", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + query := "" + if len(args) == 1 { + query = args[0] + } + runWorkspaceSearch(cmd.Context(), query) + }, + } +} + +// runWorkspaceList fetches the caller's workspaces from the portal. +// +// A token is mandatory here: /api/workspaces resolves the viewer from the +// bearer token and fail-softs to an empty list for anyone it cannot identify, +// so running without one would print "no workspaces" instead of "not logged +// in". The absent-token case is therefore rejected locally, before the call. +func runWorkspaceList(ctx context.Context, asJSON bool) { + config, err := LoadPubPascalConfig() + if err != nil { + msg.Die("โŒ Failed to load PubPascal configuration: %s", flattenDetail(err.Error())) + } + + if config.AuthToken == "" { + msg.Die("โŒ You must log in first. Run 'boss login --token <token>' with your portal token.") + } + + body, err := getPortalJSON(ctx, portalEndpoint(config, "/api/workspaces"), config.AuthToken) + if err != nil { + msg.Die("โŒ Failed to list workspaces: %s", err) + } + + var payload workspaceListPayload + if err := json.Unmarshal(body, &payload); err != nil { + msg.Die("โŒ The portal did not return a workspace list: %s", flattenDetail(err.Error())) + } + if payload.Workspaces == nil { + payload.Workspaces = []json.RawMessage{} + } + + if asJSON { + printJSONPayload(payload) + + return + } + + printWorkspaceSummaries(payload.Workspaces) +} + +// runWorkspaceSearch queries the public package catalog and echoes the payload. +// +// No credential is attached: /api/packages/catalog is public, and requiring a +// login to browse packages would lock the desktop cockpit's discover tab behind +// an account it does not need. +func runWorkspaceSearch(ctx context.Context, query string) { + config, err := LoadPubPascalConfig() + if err != nil { + msg.Die("โŒ Failed to load PubPascal configuration: %s", flattenDetail(err.Error())) + } + + endpoint := portalEndpoint(config, "/api/packages/catalog") + if query = strings.TrimSpace(query); query != "" { + endpoint += "?q=" + url.QueryEscape(query) + } + + body, err := getPortalJSON(ctx, endpoint, "") + if err != nil { + msg.Die("โŒ Failed to search the package catalog: %s", err) + } + + var payload catalogPayload + if err := json.Unmarshal(body, &payload); err != nil { + msg.Die("โŒ The portal did not return a package catalog: %s", flattenDetail(err.Error())) + } + if payload.Packages == nil { + payload.Packages = []json.RawMessage{} + } + + printJSONPayload(payload) +} + +// portalEndpoint joins the configured portal base URL with an API path. +func portalEndpoint(config *PubPascalConfig, path string) string { + return strings.TrimSuffix(config.PortalBaseURL, "/") + path +} + +// getPortalJSON performs a bounded GET against the portal and returns the body. +// An empty authToken sends no Authorization header, which is what the public +// endpoints expect. +func getPortalJSON(ctx context.Context, endpoint string, authToken string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("could not build the request: %w", err) + } + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + req.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: portalRequestTimeout} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("network error: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := readPortalBody(resp) + if err != nil { + return nil, fmt.Errorf("could not read the portal response: %w", err) + } + + switch resp.StatusCode { + case http.StatusOK: + return body, nil + case http.StatusUnauthorized, http.StatusForbidden: + return nil, errPortalUnauthorized + case http.StatusTooManyRequests: + return nil, errPortalRateLimited + default: + return nil, fmt.Errorf("portal returned HTTP %d: %s", resp.StatusCode, portalErrorDetail(body)) + } +} + +// printJSONPayload writes a payload as a single JSON line on standard output. +// +// Standard output is the contract: the PubPascal host merges the child's stdout +// and stderr and then recovers the payload by slicing from the first brace to +// the last brace of everything the process printed. Nothing else may be written +// after the payload, and no failure path may print a brace. +func printJSONPayload(payload any) { + data, err := json.Marshal(payload) + if err != nil { + msg.Die("โŒ Failed to encode the JSON payload: %s", flattenDetail(err.Error())) + } + + if _, err := fmt.Fprintln(os.Stdout, string(data)); err != nil { + msg.Die("โŒ Failed to write the JSON payload: %s", flattenDetail(err.Error())) + } +} + +// printWorkspaceSummaries prints the human-readable workspace report. +func printWorkspaceSummaries(entries []json.RawMessage) { + if len(entries) == 0 { + msg.Info("No workspaces found for this account.") + + return + } + + msg.Info("Workspaces (%d):", len(entries)) + for _, entry := range entries { + var summary workspaceSummary + if err := json.Unmarshal(entry, &summary); err != nil { + msg.Warn(" (skipped an entry the portal sent in an unexpected shape)") + + continue + } + msg.Info(" %s %s", summary.ID, summary.Name) + } +} + +// portalErrorDetail reduces a portal error body to a short, brace-free string. +func portalErrorDetail(body []byte) string { + return flattenDetail(portalErrorMessage(body)) +} + +// flattenDetail makes an arbitrary error string safe to print next to a JSON +// contract: braces are removed and the text is collapsed onto a single, capped +// line. +// +// The PubPascal host recovers a payload by slicing from the first brace to the +// last brace of the whole captured output, so an error body that carried braces +// -- a JSON blob, an HTML page with inline CSS -- would be picked up and parsed +// as if it were the command's result. +func flattenDetail(detail string) string { + replacer := strings.NewReplacer("{", "", "}", "", "\r", " ", "\n", " ", "\t", " ") + flattened := strings.TrimSpace(replacer.Replace(detail)) + + runes := []rune(flattened) + if len(runes) > maxErrorDetailRunes { + return string(runes[:maxErrorDetailRunes]) + "..." + } + + return flattened +} diff --git a/internal/adapters/primary/cli/workspace_status.go b/internal/adapters/primary/cli/workspace_status.go new file mode 100644 index 0000000..de7271e --- /dev/null +++ b/internal/adapters/primary/cli/workspace_status.go @@ -0,0 +1,488 @@ +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/hashload/boss/pkg/msg" + "github.com/spf13/cobra" +) + +// subCmdNameStatus reports the state of every repository of a workspace. +const subCmdNameStatus = "status" + +// aheadBehindFieldCount is how many counters 'git rev-list --left-right +// --count HEAD...@{u}' prints: the commits only HEAD has, then the ones only +// the upstream has. +const aheadBehindFieldCount = 2 + +// workspaceStatusPayload is the graph the PubPascal desktop app renders. +// +// The field names below are the contract: window.loadGraph reads nodes[] and +// each node's id, name, root, ref, branch, ahead, behind, dirty, missing and +// writable. Renaming any of them silently blanks a part of the graph, because +// the page defaults every field it does not find instead of failing. +type workspaceStatusPayload struct { + Nodes []workspaceStatusNode `json:"nodes"` +} + +// workspaceStatusNode is one repository of the workspace. +type workspaceStatusNode struct { + // ID is the directory the repository occupies: the workspace folder for the + // root, modules/<id> for every dependency. The desktop prints it as the + // module path and matches it against the boss.json dependency keys, so it + // has to be the name on disk and not the portal's node UUID. + ID string `json:"id"` + // Name is the label shown on the node. + Name string `json:"name"` + // Root marks the PAI repository, the one that owns modules/. + Root bool `json:"root"` + // Ref is what the checkout points at: the branch name, or the tag (or short + // commit) of a detached HEAD. For a repository missing from disk it falls + // back to the reference the portal manifest pins. + Ref string `json:"ref"` + // Branch is the checked-out branch, empty on a detached HEAD. + Branch string `json:"branch"` + // Ahead and Behind count the commits between HEAD and its upstream. Both + // stay zero when the branch tracks no remote, which is not an error. + Ahead int `json:"ahead"` + Behind int `json:"behind"` + // Dirty reports uncommitted changes among the repository's own files. The + // nested modules/ clones are excluded: they are separate nodes of this very + // graph, and counting them would paint every root dirty forever. + Dirty bool `json:"dirty"` + // Missing means the portal declares the repository but it is not a git + // repository on disk -- not cloned yet, or a clone that failed halfway. + Missing bool `json:"missing"` + // Writable means the portal grants a push target for it. It is only ever + // true when the manifest says so: without the manifest there is no evidence + // of write access, and claiming it would offer a push that cannot succeed. + Writable bool `json:"writable"` +} + +// repoGitState is everything this command reads out of a local repository. +type repoGitState struct { + ref string + branch string + ahead int + behind int + dirty bool +} + +// newWorkspaceStatusCmd builds 'boss workspace status'. +func newWorkspaceStatusCmd() *cobra.Command { + var asJSON bool + + cmd := &cobra.Command{ + Use: subCmdNameStatus + " [workspace-id]", + Short: "Show status (ahead/behind/dirty) for each repository in the workspace", + Long: "Show status (ahead/behind/dirty) for each repository in the workspace.\n\n" + + "Without an argument the workspace is detected from the current directory and " + + "only git is consulted. Given a workspace id -- or a \"<package-slug>@<version>\" " + + "reference -- the portal manifest is fetched first, so the report also knows which " + + "repositories are declared but absent from disk, and which ones you may push to.\n\n" + + "With --json the result is printed on standard output as an object holding a " + + "\"nodes\" array of id/name/root/ref/branch/ahead/behind/dirty/missing/writable " + + "entries: the graph the PubPascal desktop app renders.", + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ref := "" + if len(args) == 1 { + ref = args[0] + } + runWorkspaceStatusCommand(cmd.Context(), ref, asJSON) + }, + } + + cmd.Flags().BoolVar(&asJSON, flagNameJSON, false, "print the workspace graph as JSON on standard output") + + return cmd +} + +// runWorkspaceStatusCommand dispatches the four shapes of this command. +func runWorkspaceStatusCommand(ctx context.Context, ref string, asJSON bool) { + ref = strings.TrimSpace(ref) + + // The report that existed before this flag is left exactly as it was: no + // argument and no --json still walks the current directory and prints the + // same lines, so nothing that reads 'boss workspace status' today breaks. + if ref == "" && !asJSON { + runWorkspaceStatus(ctx) + + return + } + + if asJSON { + // Everything printed on the way to the payload lands in the same pipe + // the host reads, and the host recovers the payload by slicing from the + // first brace to the last one. The progress chatter carries no brace, + // but it would still end up quoted in the host log, so it is muted and + // the payload -- printed last -- is all that is left on standard output. + msg.SetQuietMode(true) + } + + nodes := collectWorkspaceStatusNodes(ctx, ref) + + if asJSON { + printJSONPayload(workspaceStatusPayload{Nodes: nodes}) + + return + } + + printWorkspaceStatusText(nodes) +} + +// collectWorkspaceStatusNodes builds the node list for the requested workspace. +func collectWorkspaceStatusNodes(ctx context.Context, ref string) []workspaceStatusNode { + cwd, err := os.Getwd() + if err != nil { + msg.Die("โŒ Failed to get current directory: %s", flattenDetail(err.Error())) + } + + if ref == "" { + return localWorkspaceStatusNodes(ctx, cwd) + } + + return manifestWorkspaceStatusNodes(ctx, cwd, ref) +} + +// manifestWorkspaceStatusNodes correlates the portal manifest of a workspace +// with the repositories found on disk. +// +// The manifest is the only source for three of the fields: which repository is +// the root, which ones are writable, and -- by declaring them at all -- which +// ones are missing. git cannot answer any of those. +func manifestWorkspaceStatusNodes(ctx context.Context, cwd string, ref string) []workspaceStatusNode { + config, err := LoadPubPascalConfig() + if err != nil { + msg.Die("โŒ Failed to load PubPascal configuration: %s", flattenDetail(err.Error())) + } + + if config.AuthToken == "" { + msg.Die("โŒ You must log in first. Run 'boss login --token <token>' with your portal token.") + } + + // The reference is echoed back by the resolver error messages, and a brace + // reaching the host output would be mistaken for the start of the payload. + // Rejecting it here keeps every failure path of this command brace-free + // without silently mangling what the user typed. + if strings.ContainsAny(ref, "{}") { + msg.Die("โŒ The workspace reference contains a brace, which is valid neither in a " + + "workspace id nor in a <package-slug>@<version> reference.") + } + + manifest := fetchWorkspaceManifest(ctx, config, resolveWorkspaceRef(ctx, config, ref)) + + rootDir := resolveRootRepoName(manifest.Repos) + if rootDir == "" { + msg.Die("โŒ The workspace manifest declares no root (PAI) repository, " + + "so its dependencies cannot be located on disk.") + } + + repos := orderReposRootFirst(manifest.Repos) + nodes := make([]workspaceStatusNode, 0, len(repos)) + for _, repo := range repos { + nodes = append(nodes, manifestRepoNode(ctx, cwd, rootDir, repo)) + } + + return nodes +} + +// manifestRepoNode reports one declared repository, present on disk or not. +func manifestRepoNode(ctx context.Context, cwd string, rootDir string, repo ManifestRepo) workspaceStatusNode { + dir := manifestRepoDirName(repo) + + node := workspaceStatusNode{ + ID: dir, + Name: manifestRepoDisplayName(repo, dir), + Root: repo.IsRoot, + Ref: repo.Ref.Value, + Branch: "", + Ahead: 0, + Behind: 0, + Dirty: false, + Missing: true, + Writable: repo.Writable, + } + + if dir == "" { + // Nothing in the entry maps to a directory name, so there is no place + // on disk to look at -- which is also why 'boss workspace clone' skips + // it. The node still appears, identified by the portal own node id: + // dropping it would understate the workspace. + node.ID = repo.NodeID + node.Name = manifestRepoDisplayName(repo, repo.NodeID) + + return node + } + + repoPath := filepath.Join(cwd, dir) + if !repo.IsRoot { + repoPath = filepath.Join(cwd, rootDir, modulesDirName, dir) + } + + if !isGitRepo(repoPath) { + return node + } + + node.Missing = false + applyGitState(&node, readRepoGitState(ctx, repoPath)) + + return node +} + +// applyGitState copies the live git facts onto a node, keeping the reference +// declared by the manifest when the checkout cannot name one. +func applyGitState(node *workspaceStatusNode, state repoGitState) { + if state.ref != "" { + node.Ref = state.ref + } + node.Branch = state.branch + node.Ahead = state.ahead + node.Behind = state.behind + node.Dirty = state.dirty +} + +// manifestRepoDirName returns the directory a manifest entry occupies on disk. +// +// The first choice is the same expression 'boss workspace clone' uses, so both +// commands agree on where a repository lives. The fallbacks only matter for an +// entry clone would have skipped outright: reporting it as missing is then the +// honest answer, and it beats dropping the node from the graph. +func manifestRepoDirName(repo ManifestRepo) string { + if dir := repoShortName(repo.Name); dir != "" { + return dir + } + if dir := repoShortName(repo.Slug); dir != "" { + return dir + } + + return repoShortName(strings.TrimSuffix(repo.CloneURL, ".git")) +} + +// manifestRepoDisplayName returns the label of a node, falling back to its +// directory name when the portal has no name for it -- an external repository +// listed for someone who does not own the workspace carries none. +func manifestRepoDisplayName(repo ManifestRepo, fallback string) string { + if name := strings.TrimSpace(repo.Name); name != "" { + return name + } + if slug := strings.TrimSpace(repo.Slug); slug != "" { + return slug + } + + return fallback +} + +// localWorkspaceStatusNodes reports the workspace found in the current +// directory, without asking the portal anything. +// +// This is the offline answer: it fills in every field git can prove and leaves +// missing and writable false, because "declared" and "you may push here" are +// statements only the manifest can make. +func localWorkspaceStatusNodes(ctx context.Context, cwd string) []workspaceStatusNode { + nodes := make([]workspaceStatusNode, 0) + seen := make(map[string]bool) + + add := func(repoPath string) { + if seen[repoPath] { + return + } + seen[repoPath] = true + nodes = append(nodes, localRepoNode(ctx, repoPath)) + } + + // The desktop app spawns this CLI inside the folder the user opened, which + // is the workspace root itself as often as it is the folder holding it. + if isGitRepo(cwd) { + add(cwd) + for _, modulePath := range discoverModuleRepos(filepath.Join(cwd, modulesDirName)) { + add(modulePath) + } + } + + for _, repoPath := range discoverWorkspaceRepos(cwd) { + add(repoPath) + } + + return orderNodesRootFirst(nodes) +} + +// localRepoNode reports a repository discovered on disk. +func localRepoNode(ctx context.Context, repoPath string) workspaceStatusNode { + name := filepath.Base(repoPath) + state := readRepoGitState(ctx, repoPath) + + return workspaceStatusNode{ + ID: name, + Name: name, + Root: ownsModulesDir(repoPath), + Ref: state.ref, + Branch: state.branch, + Ahead: state.ahead, + Behind: state.behind, + Dirty: state.dirty, + // Missing stays false: nothing was declared, so nothing can be absent. + Missing: false, + // Writable stays false on purpose. See workspaceStatusNode.Writable. + Writable: false, + } +} + +// ownsModulesDir reports whether a repository holds the dependency clones of a +// workspace, which is how the root (PAI) repository is recognised on disk. +func ownsModulesDir(repoPath string) bool { + info, err := os.Stat(filepath.Join(repoPath, modulesDirName)) + + return err == nil && info.IsDir() +} + +// orderNodesRootFirst moves the root nodes to the front, keeping the relative +// order of everything else, so the report does not depend on the order the +// directories happen to be listed in. +func orderNodesRootFirst(nodes []workspaceStatusNode) []workspaceStatusNode { + ordered := make([]workspaceStatusNode, 0, len(nodes)) + for _, node := range nodes { + if node.Root { + ordered = append(ordered, node) + } + } + for _, node := range nodes { + if !node.Root { + ordered = append(ordered, node) + } + } + + return ordered +} + +// readRepoGitState reads the branch, the checked-out reference, the +// ahead/behind counters and the dirty flag of one repository. +// +// Every step degrades to its zero value instead of failing: a repository with +// no upstream, no tag or no commit at all is a normal member of a workspace, +// and refusing to report the rest of its state would blank the whole graph. +func readRepoGitState(ctx context.Context, repoPath string) repoGitState { + var state repoGitState + + // symbolic-ref answers only on a real branch, which is what tells a branch + // checkout apart from a detached HEAD: 'rev-parse --abbrev-ref HEAD' says + // "HEAD" for the detached case, and that would be read as a branch name. + if out, ok := gitCapture(ctx, repoPath, "symbolic-ref", "--quiet", "--short", gitHeadRef); ok { + state.branch = strings.TrimSpace(out) + } + + state.ref = state.branch + if state.ref == "" { + state.ref = detachedHeadRef(ctx, repoPath) + } + + if out, ok := gitCapture(ctx, repoPath, "rev-list", "--left-right", "--count", + gitHeadRef+"...@{u}"); ok { + state.ahead, state.behind = parseAheadBehind(out) + } + + if status, ok := workspaceRepoStatus(ctx, repoPath); ok { + state.dirty = status != "" + } + + return state +} + +// detachedHeadRef names the commit a detached HEAD sits on: the exact tag when +// there is one -- which is how a repository pinned to a version reads -- and +// the short commit id otherwise. +func detachedHeadRef(ctx context.Context, repoPath string) string { + if out, ok := gitCapture(ctx, repoPath, "describe", "--tags", "--exact-match"); ok { + if tag := strings.TrimSpace(out); tag != "" { + return tag + } + } + + if out, ok := gitCapture(ctx, repoPath, "rev-parse", "--short", gitHeadRef); ok { + return strings.TrimSpace(out) + } + + return "" +} + +// parseAheadBehind reads the two counters git prints, returning zeros for any +// output that is not exactly two numbers. +func parseAheadBehind(out string) (int, int) { + fields := strings.Fields(out) + if len(fields) != aheadBehindFieldCount { + return 0, 0 + } + + ahead, err := strconv.Atoi(fields[0]) + if err != nil { + return 0, 0 + } + + behind, err := strconv.Atoi(fields[1]) + if err != nil { + return 0, 0 + } + + return ahead, behind +} + +// printWorkspaceStatusText prints the human-readable report of a node list. +func printWorkspaceStatusText(nodes []workspaceStatusNode) { + if len(nodes) == 0 { + msg.Info("No repository found for this workspace in the current directory.") + + return + } + + for _, node := range nodes { + label := node.ID + if node.Root { + label += " (Root)" + } + + // Written straight to standard output to keep the aligned columns of + // the report that existed before this command learned about the portal. + _, _ = fmt.Fprintf(os.Stdout, "%-35s [%s] %s\n", label, nodeStatusWord(node), nodeStatusDetail(node)) + } +} + +// nodeStatusWord reduces a node to the single word the report shows first. +func nodeStatusWord(node workspaceStatusNode) string { + switch { + case node.Missing: + return "missing" + case node.Dirty: + return "dirty" + default: + return "clean" + } +} + +// nodeStatusDetail spells out the reference, the branch and the divergence. +func nodeStatusDetail(node workspaceStatusNode) string { + if node.Missing { + if node.Ref == "" { + return "not cloned in this directory" + } + + return "not cloned in this directory, pinned to " + node.Ref + } + + detail := "ref: " + node.Ref + if node.Branch != "" && node.Branch != node.Ref { + detail += ", branch: " + node.Branch + } + if node.Ahead != 0 || node.Behind != 0 { + detail += fmt.Sprintf(" (ahead %d, behind %d)", node.Ahead, node.Behind) + } + if node.Writable { + detail += ", writable" + } + + return detail +} diff --git a/internal/adapters/primary/cli/workspace_status_test.go b/internal/adapters/primary/cli/workspace_status_test.go new file mode 100644 index 0000000..eac2cfe --- /dev/null +++ b/internal/adapters/primary/cli/workspace_status_test.go @@ -0,0 +1,414 @@ +//nolint:testpackage // exercises unexported command plumbing +package cli + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestWorkspaceStatusPayloadShape pins every field name the PubPascal desktop +// graph reads. window.loadGraph defaults whatever it does not find, so a +// renamed field does not fail: it silently blanks part of the graph. +func TestWorkspaceStatusPayloadShape(t *testing.T) { + out, err := json.Marshal(workspaceStatusPayload{Nodes: []workspaceStatusNode{{ + ID: "janus", + Name: "Janus", + Root: true, + Ref: "v2.22.5", + Branch: "main", + Ahead: 2, + Behind: 1, + Dirty: true, + Missing: false, + Writable: true, + }}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + const want = `{"nodes":[{"id":"janus","name":"Janus","root":true,"ref":"v2.22.5",` + + `"branch":"main","ahead":2,"behind":1,"dirty":true,"missing":false,"writable":true}]}` + if string(out) != want { + t.Errorf("got %s\nwant %s", out, want) + } +} + +// TestWorkspaceStatusEmptyNodesMarshalAsArray checks that a workspace with no +// repository is an empty array and not null: the page calls .map on it. +func TestWorkspaceStatusEmptyNodesMarshalAsArray(t *testing.T) { + out, err := json.Marshal(workspaceStatusPayload{Nodes: []workspaceStatusNode{}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != `{"nodes":[]}` { + t.Errorf("got %s, want %s", out, `{"nodes":[]}`) + } +} + +// TestParseAheadBehind covers the shapes git can hand back, including the +// failure of 'rev-list HEAD...@{u}' on a branch that tracks no remote. +func TestParseAheadBehind(t *testing.T) { + cases := []struct { + in string + ahead int + behind int + }{ + {"2\t1\n", 2, 1}, + {"0\t0\n", 0, 0}, + {"", 0, 0}, + {"fatal: no upstream configured", 0, 0}, + {"7", 0, 0}, + } + + for _, tc := range cases { + ahead, behind := parseAheadBehind(tc.in) + if ahead != tc.ahead || behind != tc.behind { + t.Errorf("%q gave (%d,%d), want (%d,%d)", tc.in, ahead, behind, tc.ahead, tc.behind) + } + } +} + +// TestManifestRepoDirNameFallsBack proves a node still gets an identity when +// the portal sends no name -- an external repository seen by a non-owner has +// name and slug null. +func TestManifestRepoDirNameFallsBack(t *testing.T) { + cases := []struct { + name string + repo ManifestRepo + want string + }{ + {"name wins", ManifestRepo{Name: "isaquepinheiro/janus", Slug: "janus", + CloneURL: "https://github.com/isaquepinheiro/other.git"}, "janus"}, + {"slug when unnamed", ManifestRepo{Slug: "fluentquery", + CloneURL: "https://github.com/acme/other.git"}, "fluentquery"}, + {"clone url last", ManifestRepo{CloneURL: "https://github.com/acme/dataengine.git"}, "dataengine"}, + {"nothing usable", ManifestRepo{}, ""}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := manifestRepoDirName(tc.repo); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} + +// TestOrderNodesRootFirst keeps the report independent of the order the +// directories happen to be listed in. +func TestOrderNodesRootFirst(t *testing.T) { + got := orderNodesRootFirst([]workspaceStatusNode{ + {ID: "a"}, {ID: "root", Root: true}, {ID: "b"}, + }) + + want := []string{"root", "a", "b"} + for i, node := range got { + if node.ID != want[i] { + t.Fatalf("position %d is %q, want %q", i, node.ID, want[i]) + } + } +} + +// testMainBranch is the branch every test repository is created on. It is a +// constant so the literal is not repeated across the file. +const testMainBranch = "main" + +// requireGit skips a test when git is not on PATH. +func requireGit(t *testing.T) { + t.Helper() + + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git is not available") + } +} + +// gitEnv isolates the test repositories from the developer's own git +// configuration and identity, so the assertions do not depend on the machine +// running them. +func gitEnv() []string { + return append(os.Environ(), + "GIT_CONFIG_GLOBAL="+os.DevNull, + "GIT_CONFIG_SYSTEM="+os.DevNull, + "GIT_AUTHOR_NAME=boss test", + "GIT_AUTHOR_EMAIL=test@example.invalid", + "GIT_COMMITTER_NAME=boss test", + "GIT_COMMITTER_EMAIL=test@example.invalid", + "GIT_TERMINAL_PROMPT=0", + ) +} + +// runGit runs git in dir and fails the test when it does not succeed. +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = gitEnv() + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s in %s: %v\n%s", strings.Join(args, " "), dir, err, out) + } +} + +// writeFile creates or overwrites a file inside a test repository. +func writeFile(t *testing.T, path string, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +// initRepo creates a repository with one commit on a branch named main. +func initRepo(t *testing.T, dir string) { + t.Helper() + + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + runGit(t, dir, "-c", "init.defaultBranch="+testMainBranch, "init") + writeFile(t, filepath.Join(dir, "README.md"), "first\n") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "first") +} + +// TestReadRepoGitStateOnBranch covers the ordinary case: a branch checkout with +// no upstream is clean, at zero/zero, and names the branch in both ref and +// branch -- which is how the desktop hides the redundant tracking chip. +func TestReadRepoGitStateOnBranch(t *testing.T) { + requireGit(t) + + dir := filepath.Join(t.TempDir(), "janus") + initRepo(t, dir) + + state := readRepoGitState(t.Context(), dir) + if state.branch != testMainBranch || state.ref != testMainBranch { + t.Errorf("got ref %q branch %q, want both %q", state.ref, state.branch, testMainBranch) + } + if state.ahead != 0 || state.behind != 0 { + t.Errorf("got ahead %d behind %d on a branch with no upstream, want 0/0", state.ahead, state.behind) + } + if state.dirty { + t.Error("a fresh repository was reported dirty") + } + + writeFile(t, filepath.Join(dir, "untracked.pas"), "unit untracked;\n") + + if !readRepoGitState(t.Context(), dir).dirty { + t.Error("an untracked file did not make the repository dirty") + } +} + +// TestReadRepoGitStateDetachedTag is the pinned dependency: the checkout has no +// branch, and ref must name the tag rather than the literal "HEAD" that +// 'rev-parse --abbrev-ref' returns. +func TestReadRepoGitStateDetachedTag(t *testing.T) { + requireGit(t) + + dir := filepath.Join(t.TempDir(), "fluentquery") + initRepo(t, dir) + runGit(t, dir, "tag", "v2.22.5") + runGit(t, dir, "checkout", "v2.22.5") + + state := readRepoGitState(t.Context(), dir) + if state.branch != "" { + t.Errorf("a detached HEAD reported branch %q, want empty", state.branch) + } + if state.ref != "v2.22.5" { + t.Errorf("got ref %q, want %q", state.ref, "v2.22.5") + } +} + +// TestReadRepoGitStateAheadAndBehind proves the counters against a real +// upstream, in the order the desktop displays them. +func TestReadRepoGitStateAheadAndBehind(t *testing.T) { + requireGit(t) + + root := t.TempDir() + origin := filepath.Join(root, "origin.git") + if err := os.MkdirAll(origin, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + runGit(t, origin, "-c", "init.defaultBranch="+testMainBranch, "init", "--bare") + + seed := filepath.Join(root, "seed") + initRepo(t, seed) + runGit(t, seed, "remote", "add", "origin", origin) + runGit(t, seed, "push", "-u", "origin", testMainBranch) + + work := filepath.Join(root, "work") + runGit(t, root, "clone", origin, work) + + // The upstream moves once, the clone moves once: one commit each way. + writeFile(t, filepath.Join(seed, "upstream.pas"), "unit upstream;\n") + runGit(t, seed, "add", ".") + runGit(t, seed, "commit", "-m", "upstream") + runGit(t, seed, "push") + + writeFile(t, filepath.Join(work, "local.pas"), "unit local;\n") + runGit(t, work, "add", ".") + runGit(t, work, "commit", "-m", "local") + runGit(t, work, "fetch") + + state := readRepoGitState(t.Context(), work) + if state.ahead != 1 || state.behind != 1 { + t.Errorf("got ahead %d behind %d, want 1/1", state.ahead, state.behind) + } + if state.dirty { + t.Error("a committed repository was reported dirty") + } +} + +// TestRootRepoIgnoresModulesWhenDirty is the regression that matters most for +// the graph: the dependency clones live under the root's modules/ directory and +// are untracked there, so counting them would paint the PAI dirty forever. +func TestRootRepoIgnoresModulesWhenDirty(t *testing.T) { + requireGit(t) + + root := filepath.Join(t.TempDir(), "janus") + initRepo(t, root) + + dep := filepath.Join(root, modulesDirName, "fluentquery") + initRepo(t, dep) + writeFile(t, filepath.Join(dep, "changed.pas"), "unit changed;\n") + + if readRepoGitState(t.Context(), root).dirty { + t.Error("the root repository was reported dirty because of its modules/ clones") + } + if !readRepoGitState(t.Context(), dep).dirty { + t.Error("the dependency with a new file was not reported dirty") + } +} + +// TestManifestRepoNodeCorrelatesWithDisk walks the three states a declared +// repository can be in: the root present on disk, a dependency present under +// modules/, and a dependency the portal declares but nobody cloned. +func TestManifestRepoNodeCorrelatesWithDisk(t *testing.T) { + requireGit(t) + + cwd := t.TempDir() + rootDir := "janus" + initRepo(t, filepath.Join(cwd, rootDir)) + + dep := filepath.Join(cwd, rootDir, modulesDirName, "fluentquery") + initRepo(t, dep) + runGit(t, dep, "tag", "v1.4.0") + runGit(t, dep, "checkout", "v1.4.0") + + rootNode := manifestRepoNode(t.Context(), cwd, rootDir, ManifestRepo{ + NodeID: "11111111-1111-4111-8111-111111111111", + Name: "isaquepinheiro/janus", Slug: "janus", IsRoot: true, Writable: true, + Ref: ManifestRef{Kind: refKindVersion, Value: "2.22.5"}, + }) + if rootNode.ID != "janus" || !rootNode.Root || rootNode.Missing { + t.Errorf("root node wrong: %+v", rootNode) + } + if !rootNode.Writable { + t.Error("the manifest said the root is writable and the node did not") + } + // The checkout wins over the declared pin: the graph reports what is on + // disk, and the two differ exactly when the workspace is out of date. + if rootNode.Ref != testMainBranch || rootNode.Branch != testMainBranch { + t.Errorf("root ref %q branch %q, want both main", rootNode.Ref, rootNode.Branch) + } + + depNode := manifestRepoNode(t.Context(), cwd, rootDir, ManifestRepo{ + NodeID: "22222222-2222-4222-8222-222222222222", + Name: "fluentquery", Slug: "fluentquery", + Ref: ManifestRef{Kind: refKindTag, Value: "v1.4.0"}, + }) + if depNode.Missing || depNode.Root || depNode.Writable { + t.Errorf("dependency node wrong: %+v", depNode) + } + if depNode.Ref != "v1.4.0" || depNode.Branch != "" { + t.Errorf("pinned dependency ref %q branch %q, want v1.4.0 and no branch", depNode.Ref, depNode.Branch) + } + + absent := manifestRepoNode(t.Context(), cwd, rootDir, ManifestRepo{ + NodeID: "33333333-3333-4333-8333-333333333333", + Name: "acme/dataengine", Slug: "dataengine", + Ref: ManifestRef{Kind: refKindTag, Value: "v3.0.0"}, + }) + if !absent.Missing { + t.Error("a repository that was never cloned was not reported missing") + } + if absent.ID != "dataengine" || absent.Ref != "v3.0.0" { + t.Errorf("missing node lost its identity: %+v", absent) + } + if absent.Dirty || absent.Ahead != 0 || absent.Behind != 0 { + t.Errorf("a missing repository reported git state: %+v", absent) + } +} + +// TestManifestRepoNodeKeepsUnnamedEntries makes sure a manifest entry this CLI +// cannot map to a directory still reaches the graph, flagged missing, instead +// of shrinking the workspace to the repositories with a usable name. +func TestManifestRepoNodeKeepsUnnamedEntries(t *testing.T) { + node := manifestRepoNode(t.Context(), t.TempDir(), "janus", ManifestRepo{ + NodeID: "44444444-4444-4444-8444-444444444444", + }) + + if node.ID != "44444444-4444-4444-8444-444444444444" || !node.Missing { + t.Errorf("unnamed entry wrong: %+v", node) + } +} + +// TestLocalWorkspaceStatusNodesFindsRootAndModules covers the argument-less +// run: no portal, no token, the workspace read straight off the disk. +func TestLocalWorkspaceStatusNodesFindsRootAndModules(t *testing.T) { + requireGit(t) + + cwd := t.TempDir() + root := filepath.Join(cwd, "janus") + initRepo(t, root) + initRepo(t, filepath.Join(root, modulesDirName, "fluentquery")) + initRepo(t, filepath.Join(cwd, "unrelated")) + + nodes := localWorkspaceStatusNodes(t.Context(), cwd) + if len(nodes) != 3 { + t.Fatalf("found %d repositories, want 3: %+v", len(nodes), nodes) + } + if nodes[0].ID != "janus" || !nodes[0].Root { + t.Errorf("the root did not come first: %+v", nodes) + } + + for _, node := range nodes { + if node.Missing { + t.Errorf("%s was reported missing while sitting on disk", node.ID) + } + // Without the manifest there is no evidence of write access, and + // claiming it would offer the desktop a push that cannot succeed. + if node.Writable { + t.Errorf("%s was reported writable without a manifest saying so", node.ID) + } + } +} + +// TestLocalWorkspaceStatusNodesAcceptsRootAsCwd covers the desktop app being +// opened on the workspace root itself rather than on the folder holding it. +func TestLocalWorkspaceStatusNodesAcceptsRootAsCwd(t *testing.T) { + requireGit(t) + + root := filepath.Join(t.TempDir(), "janus") + initRepo(t, root) + initRepo(t, filepath.Join(root, modulesDirName, "fluentquery")) + + nodes := localWorkspaceStatusNodes(t.Context(), root) + if len(nodes) != 2 { + t.Fatalf("found %d repositories, want 2: %+v", len(nodes), nodes) + } + if !nodes[0].Root || nodes[0].ID != "janus" { + t.Errorf("the root was not recognised: %+v", nodes) + } + if nodes[1].ID != "fluentquery" || nodes[1].Root { + t.Errorf("the dependency was not recognised: %+v", nodes) + } +} diff --git a/setup/setup.go b/setup/setup.go index 0712b9b..0e670ef 100644 --- a/setup/setup.go +++ b/setup/setup.go @@ -56,6 +56,11 @@ func Initialize() { msg.Debug("finish boss system initialization") } +// InitializeMinimal performs a lightweight composition root setup for help/version commands. +func InitializeMinimal() { + initializeInfrastructure() +} + // initializeInfrastructure sets up infrastructure dependencies. // This is the composition root where we wire up adapters to ports. func initializeInfrastructure() {