|
1 | 1 | import { execFileSync } from "child_process"; |
2 | | -import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "fs"; |
| 2 | +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, existsSync, chmodSync } from "fs"; |
3 | 3 | import path from "path"; |
4 | 4 | import { tmpdir } from "os"; |
5 | 5 |
|
@@ -651,7 +651,146 @@ assert( |
651 | 651 | 0, |
652 | 652 | ); |
653 | 653 |
|
| 654 | +// --- CLI missing: one-time install notice --- |
| 655 | +// Force the "binary not found" path by running with a PATH that contains no |
| 656 | +// allium, and an isolated XDG_CACHE_HOME so the per-machine marker is hermetic. |
| 657 | +// process.execPath is used so node itself resolves without relying on PATH. |
| 658 | + |
| 659 | +console.log("\nCLI missing — one-time install notice:"); |
| 660 | + |
| 661 | +const emptyPathDir = mkdtempSync(path.join(tmpdir(), "allium-hook-nopath-")); |
| 662 | + |
| 663 | +function runNoCli(input, extraEnv = {}) { |
| 664 | + try { |
| 665 | + execFileSync(process.execPath, [hook], { |
| 666 | + input: JSON.stringify(input), |
| 667 | + encoding: "utf-8", |
| 668 | + stdio: ["pipe", "pipe", "pipe"], |
| 669 | + env: { ...process.env, PATH: emptyPathDir, ...extraEnv }, |
| 670 | + }); |
| 671 | + return { status: 0, stderr: "" }; |
| 672 | + } catch (e) { |
| 673 | + return { status: e.status, stderr: e.stderr || "" }; |
| 674 | + } |
| 675 | +} |
| 676 | + |
| 677 | +const noticeCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-")); |
| 678 | +const noticeEnv = { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: noticeCache }; |
| 679 | + |
| 680 | +const firstNotice = runNoCli({ tool_input: { file_path: validFile } }, noticeEnv); |
| 681 | +assert("first edit with no CLI surfaces notice (exit 1)", firstNotice.status, 1); |
| 682 | +assert("notice tells the model to install the CLI", /install/i.test(firstNotice.stderr), true); |
| 683 | +assert( |
| 684 | + "notice carries a concrete install command", |
| 685 | + /cargo install allium-cli/.test(firstNotice.stderr), |
| 686 | + true, |
| 687 | +); |
| 688 | +assert( |
| 689 | + "persisted notice promises it fires only once", |
| 690 | + /only once per machine/.test(firstNotice.stderr), |
| 691 | + true, |
| 692 | +); |
| 693 | + |
| 694 | +const secondNotice = runNoCli({ tool_input: { file_path: validFile } }, noticeEnv); |
| 695 | +assert("notice fires only once (subsequent edits exit 0)", secondNotice.status, 0); |
| 696 | +assert("subsequent edit emits nothing", secondNotice.stderr, ""); |
| 697 | + |
| 698 | +// A fresh cache (e.g. another machine) shows the notice again. |
| 699 | +const freshCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-")); |
| 700 | +const freshNotice = runNoCli( |
| 701 | + { tool_input: { file_path: validFile } }, |
| 702 | + { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: freshCache }, |
| 703 | +); |
| 704 | +assert("notice shows again under a fresh cache (exit 1)", freshNotice.status, 1); |
| 705 | + |
| 706 | +// Scope: the notice must NOT leak onto non-spec edits even when the CLI is |
| 707 | +// absent — those exit early, before the checker is ever invoked. |
| 708 | +const scopeCache = mkdtempSync(path.join(tmpdir(), "allium-hook-cache-")); |
| 709 | + |
| 710 | +const mdEdit = runNoCli( |
| 711 | + { tool_input: { file_path: path.join(projectRoot, "notes.md") } }, |
| 712 | + { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: scopeCache }, |
| 713 | +); |
| 714 | +assert("no notice on non-.allium edit when CLI absent (exit 0)", mdEdit.status, 0); |
| 715 | +assert("non-.allium edit emits nothing", mdEdit.stderr, ""); |
| 716 | + |
| 717 | +const outOfRootEdit = runNoCli( |
| 718 | + { tool_input: { file_path: outsideFile } }, |
| 719 | + { CLAUDE_PROJECT_ROOT: projectRoot, XDG_CACHE_HOME: scopeCache }, |
| 720 | +); |
| 721 | +assert("no notice on out-of-root .allium edit when CLI absent (exit 0)", outOfRootEdit.status, 0); |
| 722 | +assert("out-of-root edit emits nothing", outOfRootEdit.stderr, ""); |
| 723 | + |
| 724 | +// A blocked cache: XDG_CACHE_HOME points at a file, so the per-machine marker |
| 725 | +// can't be written. Shared by the fallback and both-unwritable scenarios. |
| 726 | +const blockedRoot = mkdtempSync(path.join(tmpdir(), "allium-hook-blocked-")); |
| 727 | +const blockedCache = path.join(blockedRoot, "not-a-dir"); |
| 728 | +writeFileSync(blockedCache, "x\n"); |
| 729 | + |
| 730 | +// Fallback: cache unwritable but project root writable → the marker falls back |
| 731 | +// to .allium-cli-notice-shown in the project root, so the notice still fires |
| 732 | +// only once (per project) and doesn't crash. |
| 733 | +const fallbackProject = mkdtempSync(path.join(tmpdir(), "allium-hook-fallback-")); |
| 734 | +const fallbackFile = path.join(fallbackProject, "spec.allium"); |
| 735 | +writeFileSync(fallbackFile, "-- allium: 3\n"); |
| 736 | +const fallbackEnv = { CLAUDE_PROJECT_ROOT: fallbackProject, XDG_CACHE_HOME: blockedCache }; |
| 737 | + |
| 738 | +const fb1 = runNoCli({ tool_input: { file_path: fallbackFile } }, fallbackEnv); |
| 739 | +assert("notice shown when cache unwritable, via project fallback (exit 1)", fb1.status, 1); |
| 740 | +assert( |
| 741 | + "fallback notice names the project marker file", |
| 742 | + /\.allium-cli-notice-shown/.test(fb1.stderr), |
| 743 | + true, |
| 744 | +); |
| 745 | +assert( |
| 746 | + "fallback notice does not claim per-machine once-only", |
| 747 | + /only once per machine/.test(fb1.stderr), |
| 748 | + false, |
| 749 | +); |
| 750 | +assert( |
| 751 | + "project fallback marker file is actually created", |
| 752 | + existsSync(path.join(fallbackProject, ".allium-cli-notice-shown")), |
| 753 | + true, |
| 754 | +); |
| 755 | +const fb2 = runNoCli({ tool_input: { file_path: fallbackFile } }, fallbackEnv); |
| 756 | +assert("project fallback marker suppresses re-firing (exit 0)", fb2.status, 0); |
| 757 | +assert("suppressed fallback edit emits nothing", fb2.stderr, ""); |
| 758 | + |
| 759 | +// Both unwritable: cache blocked AND project root read-only → no marker can be |
| 760 | +// persisted, so the hook hands off to manual install and keeps re-firing. |
| 761 | +// (Skipped under root, which bypasses directory permissions.) |
| 762 | +const roProject = mkdtempSync(path.join(tmpdir(), "allium-hook-roproj-")); |
| 763 | +const roFile = path.join(roProject, "spec.allium"); |
| 764 | +writeFileSync(roFile, "-- allium: 3\n"); |
| 765 | +chmodSync(roProject, 0o500); |
| 766 | +const runningAsRoot = typeof process.getuid === "function" && process.getuid() === 0; |
| 767 | +if (!runningAsRoot) { |
| 768 | + const roEnv = { CLAUDE_PROJECT_ROOT: roProject, XDG_CACHE_HOME: blockedCache }; |
| 769 | + const ro1 = runNoCli({ tool_input: { file_path: roFile } }, roEnv); |
| 770 | + assert("notice shown when neither marker can be saved (exit 1)", ro1.status, 1); |
| 771 | + assert( |
| 772 | + "both-unwritable notice tells the user it couldn't be saved", |
| 773 | + /could NOT be saved/.test(ro1.stderr), |
| 774 | + true, |
| 775 | + ); |
| 776 | + assert( |
| 777 | + "both-unwritable notice asks the user to confirm self-install", |
| 778 | + /confirm they're happy/.test(ro1.stderr), |
| 779 | + true, |
| 780 | + ); |
| 781 | + const ro2 = runNoCli({ tool_input: { file_path: roFile } }, roEnv); |
| 782 | + assert("both-unwritable notice re-fires (exit 1)", ro2.status, 1); |
| 783 | +} |
| 784 | +chmodSync(roProject, 0o700); |
| 785 | + |
654 | 786 | // Clean up |
| 787 | +rmSync(emptyPathDir, { recursive: true }); |
| 788 | +rmSync(noticeCache, { recursive: true }); |
| 789 | +rmSync(freshCache, { recursive: true }); |
| 790 | +rmSync(scopeCache, { recursive: true }); |
| 791 | +rmSync(blockedRoot, { recursive: true }); |
| 792 | +rmSync(fallbackProject, { recursive: true }); |
| 793 | +rmSync(roProject, { recursive: true }); |
655 | 794 | rmSync(projectRoot, { recursive: true }); |
656 | 795 | rmSync(outsideDir, { recursive: true }); |
657 | 796 | rmSync(secondRoot, { recursive: true }); |
|
0 commit comments