Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
BINARY=bin/tome-cli

.DEFAULT_GOAL := help

.PHONY: help build clean deps deps-chlogs test test-go test-e2e run tag changelog docs release

help: ## Show this help
@grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'

all: build test

build: $(BINARY)
build: $(BINARY) ## Build the binary
GO_FILES = $(shell find . -name '*.go' | grep -v /vendor/)
EMBED_FILES = $(shell find . -name '*.tmpl' | grep -v /vendor/)

$(BINARY): $(GO_FILES) $(EMBED_FILES)
go build -o bin/tome-cli

clean:
clean: ## Remove build artifacts
go clean
rm -f $(BINARY)

Expand All @@ -19,18 +26,18 @@ deps-chlogs:
deps: deps-chlogs
go mod tidy

test: build test-go test-e2e
test: build test-go test-e2e ## Run all tests (build + unit + e2e)

test/bin/wrapper.sh: build
@ $(BINARY) --executable wrapper.sh --root examples alias --output test/bin/wrapper.sh

test-e2e: test/bin/wrapper.sh build
test-e2e: test/bin/wrapper.sh build ## Run Deno E2E tests
@ deno test --allow-env --allow-read --allow-run test/*.ts

test-go:
test-go: ## Run Go unit tests
go test ./...

run: build
run: build ## Run the binary (pass ARGS=... for arguments)
$(BINARY) $(ARGS)

tag:
Expand Down
49 changes: 34 additions & 15 deletions cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ package cmd

import (
"io/fs"
"os"
"path"
"path/filepath"

"github.com/lithammer/dedent"
gitignore "github.com/sabhiram/go-gitignore"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -45,21 +47,7 @@ var helpCmd = &cobra.Command{
rootDir := config.RootDir()
ignorePatterns := config.IgnorePatterns()
if len(args) == 0 {
allExecutables := []string{}
fn := func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if isExecutableByOwner(info.Mode()) && !ignorePatterns.MatchesPath(path) {
allExecutables = append(allExecutables, path)
}
return nil
}
// TODO: does not handle symlinks, consider fb symlinkWalk instead
err := filepath.Walk(rootDir, fn)
allExecutables, err := collectExecutables(rootDir, ignorePatterns)
if err != nil {
return err
}
Expand All @@ -78,6 +66,37 @@ var helpCmd = &cobra.Command{
ValidArgsFunction: ValidArgsFunctionForScripts,
}

// collectExecutables walks rootDir and returns paths to all executable files,
// resolving symlinks to check the target's properties.
// SYMLINK-001, SYMLINK-002: symlinked executables are included.
// SYMLINK-003: broken symlinks are skipped without error.
func collectExecutables(rootDir string, ignorePatterns *gitignore.GitIgnore) ([]string, error) {
var allExecutables []string
fn := func(p string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// SYMLINK-001, SYMLINK-002: resolve symlinks to check target properties
if info.Mode()&os.ModeSymlink != 0 {
resolved, statErr := os.Stat(p)
if statErr != nil {
// SYMLINK-003: skip broken symlinks
return nil
}
info = resolved
}
if info.IsDir() {
return nil
}
if isExecutableByOwner(info.Mode()) && !ignorePatterns.MatchesPath(p) {
allExecutables = append(allExecutables, p)
}
return nil
}
err := filepath.Walk(rootDir, fn)
return allExecutables, err
}

func init() {
rootCmd.AddCommand(helpCmd)
}
225 changes: 225 additions & 0 deletions cmd/symlink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package cmd

import (
"os"
"path/filepath"
"sort"
"testing"

gitignore "github.com/sabhiram/go-gitignore"
)

// SYMLINK-001: WHEN a symlinked executable file exists in the root directory,
// the help command SHALL list it alongside regular executable files.
func TestCollectExecutables_IncludesSymlinkedFiles(t *testing.T) {
tmpDir := t.TempDir()
setupTestConfig(t, tmpDir, "tome-cli")

// Create a real executable script
realScript := filepath.Join(tmpDir, "real-script")
if err := os.WriteFile(realScript, []byte("#!/bin/bash\n# USAGE: $0 <arg>\necho 1\n"), 0755); err != nil {
t.Fatal(err)
}

// Create a symlink to it
symlink := filepath.Join(tmpDir, "linked-script")
if err := os.Symlink("real-script", symlink); err != nil {
t.Fatal(err)
}

ignorePatterns := gitignore.CompileIgnoreLines()
executables, err := collectExecutables(tmpDir, ignorePatterns)
if err != nil {
t.Fatalf("collectExecutables() returned error: %v", err)
}

// Both real and symlinked scripts should appear
if len(executables) != 2 {
t.Fatalf("expected 2 executables, got %d: %v", len(executables), executables)
}

sort.Strings(executables)
expected := []string{
filepath.Join(tmpDir, "linked-script"),
filepath.Join(tmpDir, "real-script"),
}
sort.Strings(expected)

for i, e := range expected {
if executables[i] != e {
t.Errorf("expected executables[%d]=%s, got %s", i, e, executables[i])
}
}
}

// SYMLINK-002: WHEN a symlinked executable file exists in a subdirectory,
// the help command SHALL list it alongside regular executable files in that subdirectory.
func TestCollectExecutables_IncludesSymlinksInSubdirs(t *testing.T) {
tmpDir := t.TempDir()
setupTestConfig(t, tmpDir, "tome-cli")

subDir := filepath.Join(tmpDir, "subdir")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatal(err)
}

// Create a real executable in subdir
realScript := filepath.Join(subDir, "real-script")
if err := os.WriteFile(realScript, []byte("#!/bin/bash\necho 1\n"), 0755); err != nil {
t.Fatal(err)
}

// Create a symlink in subdir
symlink := filepath.Join(subDir, "linked-script")
if err := os.Symlink("real-script", symlink); err != nil {
t.Fatal(err)
}

ignorePatterns := gitignore.CompileIgnoreLines()
executables, err := collectExecutables(tmpDir, ignorePatterns)
if err != nil {
t.Fatalf("collectExecutables() returned error: %v", err)
}

if len(executables) != 2 {
t.Fatalf("expected 2 executables, got %d: %v", len(executables), executables)
}

sort.Strings(executables)
expected := []string{
filepath.Join(subDir, "linked-script"),
filepath.Join(subDir, "real-script"),
}
sort.Strings(expected)

for i, e := range expected {
if executables[i] != e {
t.Errorf("expected executables[%d]=%s, got %s", i, e, executables[i])
}
}
}

// SYMLINK-003: WHEN a symlink points to a non-existent target (broken symlink),
// the help command SHALL skip it without error.
func TestCollectExecutables_SkipsBrokenSymlinks(t *testing.T) {
tmpDir := t.TempDir()
setupTestConfig(t, tmpDir, "tome-cli")

// Create a real executable
realScript := filepath.Join(tmpDir, "real-script")
if err := os.WriteFile(realScript, []byte("#!/bin/bash\necho 1\n"), 0755); err != nil {
t.Fatal(err)
}

// Create a broken symlink (target does not exist)
brokenLink := filepath.Join(tmpDir, "broken-link")
if err := os.Symlink("nonexistent-target", brokenLink); err != nil {
t.Fatal(err)
}

ignorePatterns := gitignore.CompileIgnoreLines()
executables, err := collectExecutables(tmpDir, ignorePatterns)
if err != nil {
t.Fatalf("collectExecutables() returned error: %v", err)
}

// Only the real script should appear, broken symlink skipped
if len(executables) != 1 {
t.Fatalf("expected 1 executable, got %d: %v", len(executables), executables)
}
if executables[0] != realScript {
t.Errorf("expected %s, got %s", realScript, executables[0])
}
}

// SYMLINK-005: WHEN a symlinked executable file exists in the root directory,
// the help command with the script name as argument SHALL display its usage and help text.
func TestNewScript_FollowsSymlinks(t *testing.T) {
tmpDir := t.TempDir()
setupTestConfig(t, tmpDir, "tome-cli")

// Create a real script with USAGE header
realScript := filepath.Join(tmpDir, "real-script")
content := "#!/bin/bash\n# USAGE: $0 <arg1> <arg2>\n# This is help text\n\necho 1\n"
if err := os.WriteFile(realScript, []byte(content), 0755); err != nil {
t.Fatal(err)
}

// Create a symlink
symlink := filepath.Join(tmpDir, "linked-script")
if err := os.Symlink("real-script", symlink); err != nil {
t.Fatal(err)
}

// NewScript should parse the symlink target and extract usage/help
s := NewScript(symlink, tmpDir)
usage := s.Usage()
if usage == "" {
t.Error("NewScript on symlink returned empty usage, expected parsed usage from target")
}
if usage != "<arg1> <arg2>" {
t.Errorf("expected usage '<arg1> <arg2>', got '%s'", usage)
}

help := s.Help()
if help == "" {
t.Error("NewScript on symlink returned empty help, expected parsed help from target")
}
}

// SYMLINK-006: WHEN a symlinked executable file exists in the root directory,
// the completion system SHALL include it in tab completion results.
func TestCompletionIncludesSymlinks(t *testing.T) {
tmpDir := t.TempDir()
setupTestConfig(t, tmpDir, "tome-cli")

// Create a real executable script
realScript := filepath.Join(tmpDir, "real-script")
if err := os.WriteFile(realScript, []byte("#!/bin/bash\n# USAGE: $0 <arg>\necho 1\n"), 0755); err != nil {
t.Fatal(err)
}

// Create a symlink
symlink := filepath.Join(tmpDir, "linked-script")
if err := os.Symlink("real-script", symlink); err != nil {
t.Fatal(err)
}

// ReadDir is used by ValidArgsFunctionForScripts; verify symlink appears
entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("ReadDir() returned error: %v", err)
}

var names []string
for _, entry := range entries {
names = append(names, entry.Name())
}

foundLinked := false
foundReal := false
for _, name := range names {
if name == "linked-script" {
foundLinked = true
}
if name == "real-script" {
foundReal = true
}
}

if !foundLinked {
t.Error("linked-script not found in directory listing for completion")
}
if !foundReal {
t.Error("real-script not found in directory listing for completion")
}

// Verify the symlinked script is detected as executable via os.Stat (which follows symlinks)
info, err := os.Stat(symlink)
if err != nil {
t.Fatalf("os.Stat on symlink failed: %v", err)
}
if !isExecutableByOwner(info.Mode()) {
t.Error("symlinked script not detected as executable via os.Stat")
}
}
1 change: 1 addition & 0 deletions examples/symlinked-foo
23 changes: 23 additions & 0 deletions test/tome-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,14 @@ for await (let [executable, fn] of [["tome-cli", tome], ["wrapper.sh", wrapper]]
await assertSnapshot(t, out);
});

// SYMLINK-001: symlinked-foo SHALL appear in help listing
Deno.test(`top level help`, async function (t): Promise<void> {
const { lines } = await fn("help");
assertEquals(lines, [
"folder bar: <arg1> <arg2>",
"folder test-env-injection: ",
"foo: <arg1> <arg2>",
"symlinked-foo: <arg1> <arg2>",
"test-hooks:",
]);
});
Expand Down Expand Up @@ -98,6 +100,27 @@ for await (let [executable, fn] of [["tome-cli", tome], ["wrapper.sh", wrapper]]
]);
});

// SYMLINK-004: exec SHALL execute a symlinked script
Deno.test(`${executable}: exec runs symlinked script`, async function (t): Promise<void> {
const { code, lines } = await fn(`exec symlinked-foo`);
assertEquals(code, 0);
assertEquals(lines[0], "1");
});

// SYMLINK-005: help SHALL display usage for a symlinked script
Deno.test(`${executable}: help shows symlinked script usage`, async function (t): Promise<void> {
const { code, out } = await fn(`help symlinked-foo`);
assertEquals(code, 0);
assertStringIncludes(out, "<arg1> <arg2>");
assertStringIncludes(out, "This is a foo script");
});

// SYMLINK-006: completion SHALL include symlinked scripts
Deno.test(`${executable}: completion includes symlinked scripts`, async function (t): Promise<void> {
const { out } = await fn(`__complete exec sym`);
assertStringIncludes(out, "symlinked-foo");
});

Deno.test(`${executable}: injects TOME_ROOT and TOME_EXECUTABLE into environment of script`, async function (t): Promise<void> {
const { code, lines, executable } = await fn(`exec folder test-env-injection`);
assertEquals(code, 0);
Expand Down