diff --git a/bin/zero.js b/bin/zero.js index d087e4aa..dadfc012 100755 --- a/bin/zero.js +++ b/bin/zero.js @@ -49,34 +49,164 @@ function localControlHelperManifest(packageRoot) { return JSON.stringify({ version: 1, helpers }); } +// Mirrors internal/cli/observability.go parseDoctorArgs + writeDoctorHelp so the +// wrapper's missing-binary doctor fallback matches the Go doctor CLI surface. +const DOCTOR_HELP = `Usage: + zero doctor [flags] + +Runs Go backend health checks for config and provider setup. + +Flags: + --json Print JSON report + --connectivity Include provider endpoint connectivity probe when available + -h, --help Show this help +`; + +const EXIT_USAGE = 2; + +function installedByBun() { + if (process.env.ZERO_WRAPPER_SIMULATE_BUN === '1') { + return true; + } + return process.execPath.includes('bun') || !!process.versions?.bun; +} + +function bunRecoveryParagraph() { + return ( + 'You installed with Bun, which does not run dependency lifecycle scripts\n' + + 'by default. Trust the package to run the blocked postinstall:\n' + + ' bun pm trust @gitlawb/zero (project install)\n' + + ' bun pm -g trust @gitlawb/zero (global install)\n' + + 'On Bun versions without `bun pm trust`, add\n' + + ' "trustedDependencies": ["@gitlawb/zero"]\n' + + 'to your project package.json and reinstall.\n' + + '\n' + ); +} + +function buildFromSourceParagraph() { + return ( + 'If that fails, build from source: https://github.com/Gitlawb/zero\n' + + '(go run ./cmd/zero, requires Go 1.25+).' + ); +} + +function missingBinaryContextParagraph() { + return ( + 'The platform binary is fetched at install time by a postinstall script,\n' + + 'which did not run (or was skipped) for this install.' + ); +} + +function formatGenericMissingNativeBinaryMessage(postinstallScript) { + const ranByBun = installedByBun(); + return ( + '[zero] No native binary found next to the npm wrapper.\n' + + missingBinaryContextParagraph() + + '\n' + + '\n' + + 'Fix it now by running the installer manually:\n' + + ` node "${postinstallScript}"\n` + + '\n' + + (ranByBun ? bunRecoveryParagraph() : '') + + buildFromSourceParagraph() + ); +} + +function parseDoctorArgs(args) { + let json = false; + for (const arg of args) { + switch (arg) { + case '-h': + case '--help': + case 'help': + return { kind: 'help' }; + case '--json': + json = true; + break; + case '--connectivity': + break; + default: + return { kind: 'error', message: `unknown doctor flag ${JSON.stringify(arg)}` }; + } + } + return { kind: 'run', json }; +} + +function missingNativeDoctorJSONReport(postinstallScript) { + return { + generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), + ok: false, + checks: [ + { + id: 'runtime.go', + label: 'Go runtime', + status: 'fail', + message: 'Native zero binary is missing next to the npm wrapper.', + details: { + remedy: `node "${postinstallScript}"`, + }, + }, + ], + }; +} + +function missingNativeDoctorTextReport(postinstallScript) { + const ranByBun = installedByBun(); + return ( + 'Zero doctor report (' + + new Date().toISOString() + + ')\n' + + 'Overall: fail\n' + + '[fail] runtime.go - Native zero binary is missing next to the npm wrapper.\n' + + ' remedy: node "' + + postinstallScript + + '"\n' + + '\n' + + missingBinaryContextParagraph() + + (ranByBun ? '\n' + bunRecoveryParagraph().trimEnd() : '') + + '\n' + + buildFromSourceParagraph() + ); +} + const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); const nativePath = join(packageRoot, zeroBinaryName()); const localControlHelpers = localControlHelperManifest(packageRoot); if (!existsSync(nativePath)) { const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs'); - const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun; - console.error( - '[zero] No native binary found next to the npm wrapper.\n' + - 'The platform binary is fetched at install time by a postinstall script,\n' + - 'which did not run (or was skipped) for this install.\n' + - '\n' + - 'Fix it now by running the installer manually:\n' + - ` node "${postinstallScript}"\n` + - '\n' + - (ranByBun - ? 'You installed with Bun, which does not run dependency lifecycle scripts\n' + - 'by default. Trust the package to run the blocked postinstall:\n' + - ' bun pm trust @gitlawb/zero (project install)\n' + - ' bun pm -g trust @gitlawb/zero (global install)\n' + - 'On Bun versions without `bun pm trust`, add\n' + - ' "trustedDependencies": ["@gitlawb/zero"]\n' + - 'to your project package.json and reinstall.\n' + - '\n' - : '') + - 'If that fails, build from source: https://github.com/Gitlawb/zero\n' + - '(go run ./cmd/zero, requires Go 1.25+).', - ); + + // `zero doctor` is the diagnostic command: when the native binary is missing + // it's the one invocation that must NOT bail with the generic wrapper error, + // because that's exactly the blind alley issue #405 reports. Instead, surface + // a doctor-shaped FAIL line for the runtime so the user's diagnostic finds the + // real cause. We branch on a literal 'doctor' subcommand only (matching `zero + // doctor` and `zero doctor --connectivity`), preserving the existing bail for + // every other invocation (exec, providers list, TUI, --version, etc.). + const argv = process.argv.slice(2); + const isDoctor = argv.length > 0 && argv[0] === 'doctor'; + if (isDoctor) { + const parsed = parseDoctorArgs(argv.slice(1)); + if (parsed.kind === 'help') { + process.stdout.write(DOCTOR_HELP); + process.exit(0); + } + if (parsed.kind === 'error') { + process.stderr.write(`[zero] ${parsed.message}\n`); + process.exit(EXIT_USAGE); + } + + if (parsed.json) { + process.stdout.write(JSON.stringify(missingNativeDoctorJSONReport(postinstallScript), null, 2) + '\n'); + process.exit(1); + } + + process.stdout.write(missingNativeDoctorTextReport(postinstallScript) + '\n'); + process.exit(1); + } + + console.error(formatGenericMissingNativeBinaryMessage(postinstallScript)); process.exit(1); } diff --git a/internal/npmwrapper/npmwrapper_test.go b/internal/npmwrapper/npmwrapper_test.go index 12c5b372..0cd52400 100644 --- a/internal/npmwrapper/npmwrapper_test.go +++ b/internal/npmwrapper/npmwrapper_test.go @@ -12,6 +12,54 @@ import ( "time" ) +// Mirror internal/cli/exec.go exit codes so wrapper doctor fallback tests assert +// the same CLI contract as the Go doctor command. +const ( + wrapperExitSuccess = 0 + wrapperExitUsage = 2 + wrapperExitDoctor = 1 +) + +// Canonical Bun recovery copy from bin/zero.js bunRecoveryParagraph() — shared +// by the generic missing-binary path and the doctor text fallback. +const ( + bunRecoveryLead = "You installed with Bun, which does not run dependency lifecycle scripts" + bunPmTrustProject = "bun pm trust @gitlawb/zero" + bunPmTrustGlobal = "bun pm -g trust @gitlawb/zero" + bunRecoveryTrustedDeps = `"trustedDependencies": ["@gitlawb/zero"]` + buildFromSourceLead = "If that fails, build from source: https://github.com/Gitlawb/zero" +) + +func runWrapperFixture(t *testing.T, wrapperPath string, args ...string) (stdout string, stderr string, exitCode int) { + return runWrapperFixtureWithEnv(t, wrapperPath, nil, args...) +} + +func runWrapperFixtureWithEnv(t *testing.T, wrapperPath string, extraEnv []string, args ...string) (stdout string, stderr string, exitCode int) { + t.Helper() + node := requireNode(t) + ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout()) + defer cancel() + command := nodeWrapperCommand(ctx, node, wrapperPath, args...) + command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=") + command.Env = append(withoutEnvKey(command.Env, "ZERO_WRAPPER_SIMULATE_BUN"), extraEnv...) + var stdoutBuf, stderrBuf strings.Builder + command.Stdout = &stdoutBuf + command.Stderr = &stderrBuf + err := command.Run() + if ctx.Err() != nil { + t.Fatalf("wrapper timed out: %v; stdout: %s stderr: %s", ctx.Err(), stdoutBuf.String(), stderrBuf.String()) + } + exitCode = wrapperExitSuccess + if err != nil { + exitErr, ok := err.(*exec.ExitError) + if !ok { + t.Fatalf("wrapper err = %v, want *exec.ExitError; stdout=%s stderr=%s", err, stdoutBuf.String(), stderrBuf.String()) + } + exitCode = exitErr.ExitCode() + } + return stdoutBuf.String(), stderrBuf.String(), exitCode +} + func TestPackageBinPointsToNodeWrapper(t *testing.T) { root := repoRoot(t) bytes, err := os.ReadFile(filepath.Join(root, "package.json")) @@ -261,6 +309,221 @@ func TestNodeWrapperReportsMissingNativeBinary(t *testing.T) { } } +// Issue #405: `zero doctor` is the diagnostic command, so when the native +// binary is the thing that's broken it must NOT bail with the generic wrapper +// error; that's exactly the blind alley the bug report calls out. Instead it +// emits a doctor-shaped FAIL line for the runtime so the user's own diagnostic +// surfaces the real cause. We assert on the doctor-report shape (so the doctor +// UX matches what the Go-side doctor.Format produces) and on exit 1 (a missing +// binary is still a hard failure, not a pass). +func TestNodeWrapperDoctorReportsMissingNativeBinaryAsDoctorFail(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor") + if exitCode != wrapperExitDoctor { + t.Fatalf("doctor exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr) + } + if strings.TrimSpace(stderr) != "" { + t.Fatalf("doctor text report must go to stdout only, got stderr=%q", stderr) + } + // Doctor-shaped report, not the generic wrapper bail. Matches the shape the + // Go-side doctor.Format emits: a header, "Overall: ", then + // "[] - " lines. + if !strings.Contains(stdout, "Zero doctor report (") { + t.Fatalf("doctor output should start with a doctor report header, got stdout=%q", stdout) + } + if !strings.Contains(stdout, "Overall: fail") { + t.Fatalf("doctor overall must be fail when the native binary is missing, got stdout=%q", stdout) + } + if !strings.Contains(stdout, "[fail] runtime.go") { + t.Fatalf("doctor must report a failing runtime.go check, got stdout=%q", stdout) + } + if !strings.Contains(stdout, "Native zero binary is missing next to the npm wrapper") { + t.Fatalf("doctor must name the actual cause (missing native binary), got stdout=%q", stdout) + } + // The actionable remedy must point at the postinstall script that would fix + // the install, not just "build from source". + if !strings.Contains(stdout, "postinstall.mjs") { + t.Fatalf("doctor remedy should name the postinstall script, got stdout=%q", stdout) + } + // Regression guard for the original blind-alley bug: the doctor path must + // NOT emit the generic wrapper bail (that's what sent users debugging the + // wrong thing). + if strings.Contains(stdout, "[zero] No native binary found next to the npm wrapper") { + t.Fatalf("doctor must not emit the generic wrapper bail, got stdout=%q", stdout) + } +} + +func TestNodeWrapperDoctorJSONReportsMissingNativeBinaryAsJSONFail(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--json") + if exitCode != wrapperExitDoctor { + t.Fatalf("doctor --json exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr) + } + if strings.TrimSpace(stderr) != "" { + t.Fatalf("doctor --json should write machine-readable output to stdout only, got stderr=%q", stderr) + } + var report struct { + GeneratedAt string `json:"generatedAt"` + OK bool `json:"ok"` + Checks []struct { + ID string `json:"id"` + Label string `json:"label"` + Status string `json:"status"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"checks"` + } + if err := json.Unmarshal([]byte(stdout), &report); err != nil { + t.Fatalf("doctor --json stdout should be valid JSON, got %q: %v", stdout, err) + } + if report.GeneratedAt == "" { + t.Fatalf("doctor --json report should include generatedAt: %#v", report) + } + if report.OK { + t.Fatalf("doctor --json ok = true, want false: %#v", report) + } + if len(report.Checks) != 1 { + t.Fatalf("doctor --json checks length = %d, want 1: %#v", len(report.Checks), report.Checks) + } + check := report.Checks[0] + if check.ID != "runtime.go" || check.Label != "Go runtime" || check.Status != "fail" { + t.Fatalf("doctor --json runtime check = %#v, want failing runtime.go check", check) + } + if !strings.Contains(check.Message, "Native zero binary is missing next to the npm wrapper") { + t.Fatalf("doctor --json must name the actual cause, got %#v", check) + } + remedy, _ := check.Details["remedy"].(string) + if !strings.Contains(remedy, "postinstall.mjs") { + t.Fatalf("doctor --json remedy should name the postinstall script, got %#v", check.Details) + } +} + +func TestNodeWrapperDoctorHelpShowsUsage(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + for _, args := range [][]string{{"doctor", "--help"}, {"doctor", "help"}, {"doctor", "-h"}} { + stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, args...) + if exitCode != wrapperExitSuccess { + t.Fatalf("%v exit = %d, want %d; stdout=%s stderr=%s", args, exitCode, wrapperExitSuccess, stdout, stderr) + } + if strings.TrimSpace(stderr) != "" { + t.Fatalf("%v help must write to stdout only, got stderr=%q", args, stderr) + } + if !strings.Contains(stdout, "Usage:") || !strings.Contains(stdout, "zero doctor [flags]") { + t.Fatalf("%v help output = %q, want doctor usage text", args, stdout) + } + } +} + +func TestNodeWrapperDoctorRejectsUnknownFlag(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--bogus") + if exitCode != wrapperExitUsage { + t.Fatalf("doctor --bogus exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitUsage, stdout, stderr) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("invalid doctor flag should not write stdout, got %q", stdout) + } + if stderr != "[zero] unknown doctor flag \"--bogus\"\n" { + t.Fatalf("invalid doctor flag stderr = %q", stderr) + } +} + +// `doctor --connectivity` (a valid doctor invocation with a trailing flag) must +// take the same doctor-shaped path: parseDoctorArgs accepts --connectivity and +// the missing-binary fallback still reports the runtime failure. +func TestNodeWrapperDoctorWithFlagsStillReportsDoctorFail(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--connectivity") + if exitCode != wrapperExitDoctor { + t.Fatalf("doctor --connectivity exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr) + } + if strings.TrimSpace(stderr) != "" { + t.Fatalf("doctor --connectivity must write to stdout only, got stderr=%q", stderr) + } + if !strings.Contains(stdout, "[fail] runtime.go") { + t.Fatalf("doctor --connectivity must still emit the failing runtime.go line, got stdout=%q", stdout) + } + if strings.Contains(stdout, "[zero] No native binary found next to the npm wrapper") { + t.Fatalf("doctor --connectivity must not fall back to the generic bail, got stdout=%q", stdout) + } +} + +func TestNodeWrapperGenericReportsBunRecoveryWhenInstalledByBun(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + _, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}, "--version") + if exitCode != wrapperExitDoctor { + t.Fatalf("generic missing-binary exit = %d, want %d; stderr=%s", exitCode, wrapperExitDoctor, stderr) + } + assertBunRecoveryMessage(t, stderr) + if !strings.Contains(stderr, buildFromSourceLead) { + t.Fatalf("generic bun stderr should include build-from-source guidance, got %q", stderr) + } +} + +func TestNodeWrapperDoctorReportsBunRecoveryWhenInstalledByBun(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}, "doctor") + if exitCode != wrapperExitDoctor { + t.Fatalf("doctor bun exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr) + } + if strings.TrimSpace(stderr) != "" { + t.Fatalf("doctor bun report must go to stdout only, got stderr=%q", stderr) + } + if !strings.Contains(stdout, "[fail] runtime.go") { + t.Fatalf("doctor bun report must still fail runtime.go, got stdout=%q", stdout) + } + assertBunRecoveryMessage(t, stdout) + if !strings.Contains(stdout, buildFromSourceLead) { + t.Fatalf("doctor bun report should reuse generic build-from-source guidance, got stdout=%q", stdout) + } + if strings.Contains(stdout, "If reinstall fails") { + t.Fatalf("doctor bun report must not use a separate build-from-source string, got stdout=%q", stdout) + } +} + +func TestNodeWrapperDoctorAndGenericShareBunRecoveryCopy(t *testing.T) { + wrapperPath := copyWrapperFixture(t) + simulateBun := []string{"ZERO_WRAPPER_SIMULATE_BUN=1"} + _, genericStderr, _ := runWrapperFixtureWithEnv(t, wrapperPath, simulateBun, "--version") + doctorStdout, _, _ := runWrapperFixtureWithEnv(t, wrapperPath, simulateBun, "doctor") + genericBun := extractBunRecoveryBlock(genericStderr) + doctorBun := extractBunRecoveryBlock(doctorStdout) + if genericBun != doctorBun { + t.Fatalf("bun recovery copy drift:\ngeneric=%q\ndoctor=%q", genericBun, doctorBun) + } +} + +func assertBunRecoveryMessage(t *testing.T, output string) { + t.Helper() + if !strings.Contains(output, bunRecoveryLead) { + t.Fatalf("output should include bun recovery lead, got %q", output) + } + if !strings.Contains(output, bunPmTrustProject) { + t.Fatalf("output should include project bun pm trust guidance, got %q", output) + } + if !strings.Contains(output, bunPmTrustGlobal) { + t.Fatalf("output should include global bun pm trust guidance, got %q", output) + } + if !strings.Contains(output, bunRecoveryTrustedDeps) { + t.Fatalf("output should include trustedDependencies fallback guidance, got %q", output) + } + if !strings.Contains(output, "to your project package.json and reinstall.") { + t.Fatalf("output should mention package.json reinstall fallback, got %q", output) + } +} + +func extractBunRecoveryBlock(output string) string { + start := strings.Index(output, bunRecoveryLead) + if start < 0 { + return "" + } + end := strings.Index(output[start:], buildFromSourceLead) + if end < 0 { + return strings.TrimSpace(output[start:]) + } + return strings.TrimSpace(output[start : start+end]) +} + func TestNodeWrapperLaunchesNativeBinary(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("mock executable fixture uses a POSIX shell script")