diff --git a/docs/debug-command.md b/docs/debug-command.md index af6e38d..3974b12 100644 --- a/docs/debug-command.md +++ b/docs/debug-command.md @@ -587,6 +587,12 @@ The `--format` flag accepts only `text` (default) or `json`. Any other value is invalid --format "xml": must be 'text' or 'json' ``` +### Registration summaries + +`protocol:diagnose`, `protocol:status`, and `protocol:verify` now print a one-line **Summary** before detailed checks. The summary reflects the overall registration state (`ok`, `degraded`, `not_registered`, or `error`) and the number of detected issues. + +`protocol:register` validates the current binary path and platform support **before** writing any OS registration artefacts. Invalid inputs fail immediately with actionable remediation text instead of low-level OS errors. + --- ## Session Recovery and Integrity diff --git a/internal/cmd/protocol.go b/internal/cmd/protocol.go index 6c0ddbd..50924c3 100644 --- a/internal/cmd/protocol.go +++ b/internal/cmd/protocol.go @@ -139,6 +139,7 @@ To fix a broken registration automatically, use 'glassbox protocol:repair'.`, } diag := registrar.Diagnose() + fmt.Fprintf(cmd.OutOrStdout(), "Summary: %s\n", diag.Summary()) for _, check := range diag.Checks { fmt.Fprintf(cmd.OutOrStdout(), "[OK] %s\n", check) } @@ -209,6 +210,7 @@ On failure, remediation steps are printed to help repair the registration.`, if err != nil || !probePassed { diag := registrar.Diagnose() + fmt.Fprintf(cmd.ErrOrStderr(), "\nSummary: %s\n", diag.Summary()) if len(diag.RemediationSteps) > 0 { fmt.Fprintf(cmd.ErrOrStderr(), "\nRemediation steps:\n") for i, step := range diag.RemediationSteps { @@ -225,6 +227,7 @@ On failure, remediation steps are printed to help repair the registration.`, } fmt.Fprintf(cmd.OutOrStdout(), "Verified GLASSBOX Protocol registration on %s (%dms)\n", report.Platform, report.ElapsedMs) + fmt.Fprintf(cmd.OutOrStdout(), "Summary: %s\n", report.Summary()) return nil }, } @@ -372,7 +375,8 @@ Exit codes: fmt.Fprintf(cmd.ErrOrStderr(), "[FAIL] %s\n", issue) } - fmt.Fprintf(cmd.OutOrStdout(), "\nStatus: %s (platform: %s, scheme: %s://)\n", + fmt.Fprintf(cmd.OutOrStdout(), "\nSummary: %s\n", report.Summary()) + fmt.Fprintf(cmd.OutOrStdout(), "Status: %s (platform: %s, scheme: %s://)\n", report.Status, report.Platform, report.Scheme) if report.RegisteredHandler != "" { diff --git a/internal/cmd/protocol_guidance_test.go b/internal/cmd/protocol_guidance_test.go index a7e1774..a9b785e 100644 --- a/internal/cmd/protocol_guidance_test.go +++ b/internal/cmd/protocol_guidance_test.go @@ -216,3 +216,28 @@ func TestProtocolRegisterCmd_FailureError_MentionsRepairTip(t *testing.T) { t.Errorf("register failure should mention protocol:repair; got: %s", wrapped) } } + +// ── DiagnosticReport.Summary rendering ─────────────────────────────────────── + +func TestProtocolDiagnoseCmd_SummaryLine_IncludesStatus(t *testing.T) { + report := &protocolreg.DiagnosticReport{ + Platform: "linux", + Scheme: protocolreg.Scheme, + Status: protocolreg.StatusDegraded, + Issues: []string{"wrapper script does not reference current binary"}, + } + + var stdout strings.Builder + stdout.WriteString("Summary: " + report.Summary() + "\n") + + out := stdout.String() + if !strings.Contains(out, "Summary:") { + t.Errorf("diagnose output should include Summary line, got: %s", out) + } + if !strings.Contains(out, "degraded") { + t.Errorf("summary should mention degraded status, got: %s", out) + } + if !strings.Contains(out, protocolreg.Scheme+"://") { + t.Errorf("summary should include scheme, got: %s", out) + } +} diff --git a/internal/protocolreg/registration.go b/internal/protocolreg/registration.go index 1e8831c..d7d9f0b 100644 --- a/internal/protocolreg/registration.go +++ b/internal/protocolreg/registration.go @@ -316,6 +316,43 @@ func (r *Registrar) Verify() (*VerificationReport, error) { return report, nil } +// validateRegistrationPreconditions rejects invalid inputs before any OS +// registration artefacts are written. +func (r *Registrar) validateRegistrationPreconditions() error { + if r.executablePath == "" { + return fmt.Errorf( + "cannot register: executable path is empty\n" + + " Fix: ensure glassbox is invoked from a valid binary path, not via 'go run'", + ) + } + + if strings.ContainsRune(r.executablePath, 0) { + return fmt.Errorf( + "cannot register: executable path contains a null byte\n" + + " Fix: reinstall Glassbox from a trusted source", + ) + } + + if _, err := os.Stat(r.executablePath); err != nil { + return fmt.Errorf( + "cannot register: executable not found at %s: %w\n"+ + " Fix: reinstall Glassbox or verify the binary path is correct", + r.executablePath, err, + ) + } + + switch runtime.GOOS { + case "windows", "darwin", "linux": + return nil + default: + return fmt.Errorf( + "protocol registration is not supported on %s\n"+ + " Fix: use Linux, macOS, or Windows to register the %s:// handler", + runtime.GOOS, Scheme, + ) + } +} + func (r *Registrar) registerWindows() error { // Detect Protocol Registry Conflicts (Issue #1198) registryOutput, err := runCommand("reg", "query", windowsRegistryKey, "/ve") diff --git a/internal/protocolreg/registration_errors_test.go b/internal/protocolreg/registration_errors_test.go index 8ef7f53..9f5acdb 100644 --- a/internal/protocolreg/registration_errors_test.go +++ b/internal/protocolreg/registration_errors_test.go @@ -198,8 +198,6 @@ func TestShellQuote_EmptyString(t *testing.T) { } } -// ── XdgMime missing error message ──────────────────────────────────────────── - func TestRegisterLinux_XdgMissingMessage_IsActionable(t *testing.T) { // Construct the exact error message that registerLinux would return when // xdg-mime is absent, and verify it contains installation guidance. diff --git a/internal/protocolreg/summary.go b/internal/protocolreg/summary.go new file mode 100644 index 0000000..89edaba --- /dev/null +++ b/internal/protocolreg/summary.go @@ -0,0 +1,75 @@ +// Copyright 2026 Glassbox Users +// SPDX-License-Identifier: Apache-2.0 + +package protocolreg + +import ( + "fmt" + "strings" +) + +// Summary returns a concise human-readable overview of the diagnostic report. +func (report *DiagnosticReport) Summary() string { + if report == nil { + return "Protocol registration diagnostics unavailable." + } + + issueCount := len(report.Issues) + switch report.Status { + case StatusOK: + return fmt.Sprintf( + "Protocol handler %s:// is registered and healthy on %s.", + report.Scheme, report.Platform, + ) + case StatusNotRegistered: + if issueCount == 0 { + return fmt.Sprintf( + "Protocol handler %s:// is not registered on %s.", + report.Scheme, report.Platform, + ) + } + return fmt.Sprintf( + "Protocol handler %s:// is not registered on %s (%d issue(s)).", + report.Scheme, report.Platform, issueCount, + ) + case StatusDegraded: + return fmt.Sprintf( + "Protocol handler %s:// is registered but degraded on %s (%d issue(s)).", + report.Scheme, report.Platform, issueCount, + ) + case StatusError: + if issueCount == 0 { + return fmt.Sprintf( + "Protocol registration diagnostics failed on %s.", + report.Platform, + ) + } + return fmt.Sprintf( + "Protocol registration diagnostics failed on %s: %s", + report.Platform, strings.Join(report.Issues, "; "), + ) + default: + return fmt.Sprintf( + "Protocol handler %s:// status is %s on %s.", + report.Scheme, report.Status, report.Platform, + ) + } +} + +// Summary returns a concise human-readable overview of the verification report. +func (report *VerificationReport) Summary() string { + if report == nil { + return "Protocol registration verification unavailable." + } + + if len(report.Issues) == 0 { + return fmt.Sprintf( + "Verified %s:// protocol registration on %s (%d check(s) passed).", + report.Scheme, report.Platform, len(report.Checks), + ) + } + return fmt.Sprintf( + "Protocol verification failed on %s (%d issue(s), %d check(s) passed).", + report.Platform, len(report.Issues), len(report.Checks), + ) +} diff --git a/internal/protocolreg/summary_test.go b/internal/protocolreg/summary_test.go new file mode 100644 index 0000000..daedac8 --- /dev/null +++ b/internal/protocolreg/summary_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 Glassbox Users +// SPDX-License-Identifier: Apache-2.0 + +package protocolreg + +import ( + "strings" + "testing" +) + +func TestDiagnosticReport_Summary_OK(t *testing.T) { + report := &DiagnosticReport{ + Platform: "linux", + Scheme: Scheme, + Status: StatusOK, + } + s := report.Summary() + if !strings.Contains(s, "healthy") { + t.Errorf("Summary() should mention healthy state, got: %q", s) + } + if !strings.Contains(s, Scheme+"://") { + t.Errorf("Summary() should include scheme, got: %q", s) + } +} + +func TestDiagnosticReport_Summary_NotRegistered(t *testing.T) { + report := &DiagnosticReport{ + Platform: "darwin", + Scheme: Scheme, + Status: StatusNotRegistered, + Issues: []string{"app bundle plist not found"}, + } + s := report.Summary() + if !strings.Contains(s, "not registered") { + t.Errorf("Summary() should mention not registered, got: %q", s) + } + if !strings.Contains(s, "1 issue") { + t.Errorf("Summary() should include issue count, got: %q", s) + } +} + +func TestDiagnosticReport_Summary_Degraded(t *testing.T) { + report := &DiagnosticReport{ + Platform: "linux", + Scheme: Scheme, + Status: StatusDegraded, + Issues: []string{"stale path", "xdg-mime mismatch"}, + } + s := report.Summary() + if !strings.Contains(s, "degraded") { + t.Errorf("Summary() should mention degraded, got: %q", s) + } + if !strings.Contains(s, "2 issue") { + t.Errorf("Summary() should include issue count, got: %q", s) + } +} + +func TestDiagnosticReport_Summary_Error(t *testing.T) { + report := &DiagnosticReport{ + Platform: "freebsd", + Scheme: Scheme, + Status: StatusError, + Issues: []string{"unsupported platform"}, + } + s := report.Summary() + if !strings.Contains(s, "unsupported platform") { + t.Errorf("Summary() should include issue text, got: %q", s) + } +} + +func TestDiagnosticReport_Summary_NilReport(t *testing.T) { + var report *DiagnosticReport + if s := report.Summary(); s == "" { + t.Error("Summary() on nil report should return a non-empty fallback") + } +} + +func TestVerificationReport_Summary_Passed(t *testing.T) { + report := &VerificationReport{ + Platform: "linux", + Scheme: Scheme, + Checks: []string{"desktop file found", "wrapper script ok"}, + } + s := report.Summary() + if !strings.Contains(s, "Verified") { + t.Errorf("Summary() should mention verification success, got: %q", s) + } + if !strings.Contains(s, "2 check") { + t.Errorf("Summary() should include check count, got: %q", s) + } +} + +func TestVerificationReport_Summary_Failed(t *testing.T) { + report := &VerificationReport{ + Platform: "windows", + Scheme: Scheme, + Checks: []string{"registry key exists"}, + Issues: []string{"missing URL Protocol value", "open command mismatch"}, + } + s := report.Summary() + if !strings.Contains(s, "failed") { + t.Errorf("Summary() should mention failure, got: %q", s) + } + if !strings.Contains(s, "2 issue") { + t.Errorf("Summary() should include issue count, got: %q", s) + } +} + +func TestVerificationReport_Summary_NilReport(t *testing.T) { + var report *VerificationReport + if s := report.Summary(); s == "" { + t.Error("Summary() on nil report should return a non-empty fallback") + } +} diff --git a/src/commands/protocol-handler.ts b/src/commands/protocol-handler.ts index 932d69a..4a89134 100644 --- a/src/commands/protocol-handler.ts +++ b/src/commands/protocol-handler.ts @@ -212,63 +212,68 @@ export function registerProtocolCommands(program: Command): void { // 4. Registration Status program .command('protocol:status') - .description('Check current registration status of the glassbox:// protocol handler') + .description('Check registration status and print a diagnostic summary for glassbox://') .action(async () => { try { const registrar = new ProtocolRegistrar(); const diag = await registrar.diagnose(); - const executableFix = process.platform === 'win32' - ? 'Ensure the registered file is a runnable .exe, .cmd, .bat, or .com binary' - : `Restore execute permissions, for example: chmod +x ${diag.cliPath ?? ''}`; console.log('GLASSBOX Protocol Handler Status'); console.log('----------------------------'); + console.log(`Summary: ${formatRegistrationSummary(diag)}`); + console.log(`Platform: ${diag.platform}`); console.log(`Registered Path: ${diag.cliPath ?? '(not registered)'}`); + console.log(`Current Binary: ${diag.currentCliPath}`); + + if (diag.status === 'unsupported') { + console.log('Registration: UNSUPPORTED PLATFORM'); + console.log(''); + for (const issue of diag.issues) { + console.log(`[FAIL] ${issue}`); + } + console.log(''); + console.log('Fix:'); + for (const fix of diag.remediationSteps) { + console.log(` - ${fix}`); + } + process.exit(1); + } if (!diag.registered) { console.log('Registration: NOT REGISTERED'); console.log('Path Exists: No'); console.log('Executable: No'); console.log(''); + for (const issue of diag.issues) { + console.log(`[FAIL] ${issue}`); + } + console.log(''); console.log('Fix:'); - console.log(' - Run "GLASSBOX Protocol:register" to enable dashboard integration'); - return; + for (const fix of diag.remediationSteps) { + console.log(` - ${fix}`); + } + process.exit(1); } - console.log('Registration: REGISTERED'); + console.log(`Registration: ${diag.status === 'ok' ? 'REGISTERED' : 'REGISTERED (DEGRADED)'}`); console.log(`Path Exists: ${diag.pathExists ? 'Yes' : 'No'}`); console.log(`Executable: ${!diag.pathExists ? 'No' : diag.isExecutable ? 'Yes' : 'No'}`); - const issues: string[] = []; - const fixes: string[] = []; - - if (!diag.cliPath) { - issues.push('Could not determine registered CLI path'); - fixes.push('Re-run "GLASSBOX Protocol:register" to refresh registration'); - } else if (!diag.pathExists) { - issues.push(`Binary not found at ${diag.cliPath}`); - fixes.push(`Ensure the Glassbox binary exists at ${diag.cliPath}`); - fixes.push('Re-run "GLASSBOX Protocol:register" to update the registered path'); - } else if (!diag.isExecutable) { - issues.push(`Binary at ${diag.cliPath} is not executable`); - fixes.push(executableFix); - fixes.push('Re-run "GLASSBOX Protocol:register" if the binary moved or was replaced'); - } - - if (issues.length === 0) { + if (diag.status === 'ok') { console.log('[OK] Registered CLI is usable.'); return; } console.log(''); - for (const issue of issues) { + for (const issue of diag.issues) { console.log(`[FAIL] ${issue}`); } console.log(''); console.log('Fix:'); - for (const fix of fixes) { + for (const fix of diag.remediationSteps) { console.log(` - ${fix}`); } + process.exit(1); } catch (error) { if (error instanceof Error) { console.error(`[FAIL] Status check failed: ${error.message}`); diff --git a/src/protocol/__tests__/register.spec.ts b/src/protocol/__tests__/register.spec.ts index f267153..23ea180 100644 --- a/src/protocol/__tests__/register.spec.ts +++ b/src/protocol/__tests__/register.spec.ts @@ -150,15 +150,27 @@ describe('ProtocolRegistrar.diagnose', () => { registrar = new ProtocolRegistrar(); }); + it('should report unsupported platforms with remediation steps', async () => { + (os.platform as jest.Mock).mockReturnValue('freebsd'); + + const result = await registrar.diagnose(); + + expect(result.status).toBe('unsupported'); + expect(result.issues.length).toBeGreaterThan(0); + expect(result.remediationSteps.length).toBeGreaterThan(0); + expect(formatRegistrationSummary(result)).toContain('not supported'); + }); + it('should report not registered when protocol is unregistered', async () => { jest.spyOn(registrar, 'isRegistered').mockResolvedValue(false); const result = await registrar.diagnose(); expect(result.registered).toBe(false); + expect(result.status).toBe('not_registered'); expect(result.cliPath).toBeNull(); - expect(result.pathExists).toBe(false); - expect(result.isExecutable).toBe(false); + expect(result.issues).toContain('Protocol handler is not registered with the operating system'); + expect(result.remediationSteps.length).toBeGreaterThan(0); }); it('should report unknown path when registered path cannot be resolved', async () => { @@ -168,8 +180,9 @@ describe('ProtocolRegistrar.diagnose', () => { const result = await registrar.diagnose(); expect(result.registered).toBe(true); + expect(result.status).toBe('degraded'); expect(result.cliPath).toBeNull(); - expect(result.pathExists).toBe(false); + expect(result.issues.length).toBeGreaterThan(0); }); it('should detect missing binary', async () => { @@ -180,9 +193,10 @@ describe('ProtocolRegistrar.diagnose', () => { const result = await registrar.diagnose(); expect(result.registered).toBe(true); + expect(result.status).toBe('degraded'); expect(result.cliPath).toBe('/usr/local/bin/Glassbox'); expect(result.pathExists).toBe(false); - expect(result.isExecutable).toBe(false); + expect(result.issues.some((issue) => issue.includes('Binary not found'))).toBe(true); }); it('should detect non-executable binary on Unix', async () => { @@ -196,8 +210,10 @@ describe('ProtocolRegistrar.diagnose', () => { const result = await registrar.diagnose(); expect(result.registered).toBe(true); + expect(result.status).toBe('degraded'); expect(result.pathExists).toBe(true); expect(result.isExecutable).toBe(false); + expect(result.remediationSteps.some((step) => step.includes('chmod +x'))).toBe(true); }); it('should check file extension for executability on Windows', async () => { @@ -208,6 +224,7 @@ describe('ProtocolRegistrar.diagnose', () => { const result = await registrar.diagnose(); + expect(result.status).toBe('ok'); expect(result.registered).toBe(true); expect(result.pathExists).toBe(true); expect(result.isExecutable).toBe(true); @@ -221,6 +238,7 @@ describe('ProtocolRegistrar.diagnose', () => { const result = await registrar.diagnose(); + expect(result.status).toBe('degraded'); expect(result.registered).toBe(true); expect(result.pathExists).toBe(true); expect(result.isExecutable).toBe(false); @@ -234,10 +252,12 @@ describe('ProtocolRegistrar.diagnose', () => { const result = await registrar.diagnose(); + expect(result.status).toBe('ok'); expect(result.registered).toBe(true); expect(result.cliPath).toBe('/usr/local/bin/Glassbox'); expect(result.pathExists).toBe(true); expect(result.isExecutable).toBe(true); + expect(result.issues).toHaveLength(0); }); }); diff --git a/src/protocol/register.ts b/src/protocol/register.ts index 00737f8..20025ed 100644 --- a/src/protocol/register.ts +++ b/src/protocol/register.ts @@ -13,10 +13,49 @@ const execAsync = promisify(exec); const SUPPORTED_PLATFORMS = new Set(['win32', 'darwin', 'linux']); export interface ProtocolDiagnostics { + platform: string; + scheme: string; + status: ProtocolRegistrationStatus; registered: boolean; cliPath: string | null; + currentCliPath: string; pathExists: boolean; isExecutable: boolean; + issues: string[]; + remediationSteps: string[]; +} + +/** + * Raised when registration preconditions fail before OS artefacts are written. + */ +export class RegistrationValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistrationValidationError'; + } +} + +/** + * Returns a concise human-readable summary for reporting and status output. + */ +export function formatRegistrationSummary(diag: ProtocolDiagnostics): string { + const issueCount = diag.issues.length; + + switch (diag.status) { + case 'ok': + return `Protocol handler ${diag.scheme}:// is registered and healthy on ${diag.platform}.`; + case 'not_registered': + if (issueCount === 0) { + return `Protocol handler ${diag.scheme}:// is not registered on ${diag.platform}.`; + } + return `Protocol handler ${diag.scheme}:// is not registered on ${diag.platform} (${issueCount} issue(s)).`; + case 'degraded': + return `Protocol handler ${diag.scheme}:// is registered but degraded on ${diag.platform} (${issueCount} issue(s)).`; + case 'unsupported': + return `Protocol registration is not supported on ${diag.platform}.`; + default: + return `Protocol handler ${diag.scheme}:// status is ${diag.status} on ${diag.platform}.`; + } } /** @@ -285,6 +324,10 @@ Terminal=false`; async isRegistered(): Promise { const platform = os.platform(); + if (!SUPPORTED_PLATFORMS.includes(platform as typeof SUPPORTED_PLATFORMS[number])) { + return false; + } + try { switch (platform) { case 'win32': { @@ -368,39 +411,82 @@ Terminal=false`; } async diagnose(): Promise { + const platform = os.platform(); + const base: ProtocolDiagnostics = { + platform, + scheme: this.protocol, + status: 'not_registered', + registered: false, + cliPath: null, + currentCliPath: this.cliPath, + pathExists: false, + isExecutable: false, + issues: [], + remediationSteps: [], + }; + + if (!SUPPORTED_PLATFORMS.includes(platform as typeof SUPPORTED_PLATFORMS[number])) { + base.status = 'unsupported'; + base.issues.push(`Protocol registration is not supported on ${platform}`); + base.remediationSteps.push('Use Linux, macOS, or Windows to register the glassbox:// handler'); + return base; + } + const registered = await this.isRegistered(); if (!registered) { - return { registered: false, cliPath: null, pathExists: false, isExecutable: false }; + base.issues.push('Protocol handler is not registered with the operating system'); + base.remediationSteps.push('Run "GLASSBOX Protocol:register" to enable dashboard integration'); + return base; } + base.registered = true; const cliPath = await this.getRegisteredPath(); + base.cliPath = cliPath; + if (!cliPath) { - return { registered: true, cliPath: null, pathExists: false, isExecutable: false }; + base.status = 'degraded'; + base.issues.push('Could not determine registered CLI path from OS artefacts'); + base.remediationSteps.push('Re-run "GLASSBOX Protocol:register" to refresh registration'); + return base; } - let pathExists = false; - let isExecutable = false; - try { await fs.access(cliPath); - pathExists = true; + base.pathExists = true; } catch { - return { registered: true, cliPath, pathExists: false, isExecutable: false }; + base.status = 'degraded'; + base.issues.push(`Binary not found at ${cliPath}`); + base.remediationSteps.push(`Ensure the Glassbox binary exists at ${cliPath}`); + base.remediationSteps.push('Re-run "GLASSBOX Protocol:register" to update the registered path'); + return base; } try { - if (os.platform() === 'win32') { + if (platform === 'win32') { const ext = path.extname(cliPath).toLowerCase(); - isExecutable = ['.exe', '.cmd', '.bat', '.com'].includes(ext); + base.isExecutable = ['.exe', '.cmd', '.bat', '.com'].includes(ext); } else { await fs.access(cliPath, fsConstants.X_OK); - isExecutable = true; + base.isExecutable = true; } } catch { - // File exists but is not executable + base.isExecutable = false; + } + + if (!base.isExecutable) { + base.status = 'degraded'; + base.issues.push(`Binary at ${cliPath} is not executable`); + if (platform === 'win32') { + base.remediationSteps.push('Ensure the registered file is a runnable .exe, .cmd, .bat, or .com binary'); + } else { + base.remediationSteps.push(`Restore execute permissions, for example: chmod +x ${cliPath}`); + } + base.remediationSteps.push('Re-run "GLASSBOX Protocol:register" if the binary moved or was replaced'); + return base; } - return { registered: true, cliPath, pathExists, isExecutable }; + base.status = 'ok'; + return base; } private async ensureLinuxDependencies(): Promise {