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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,11 @@ Initialize a local store:

```bash
sandnote init
sandnote init --root-path /path/to/repo
```

`init` persists the notebook root path. Later commands automatically discover the nearest initialized `.sandnote` from the current directory upward, so running from subdirectories keeps using the same notebook.

Create a workspace, an entry, and a thread:

```bash
Expand Down
18 changes: 15 additions & 3 deletions internal/cli/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ func newArtifactImportCommand(opts *rootOptions) *cobra.Command {
if err != nil {
return err
}
rootPath, err := store.RootPath()
if err != nil {
return err
}

entries := make([]model.Entry, 0, len(entryIDs))
for _, entryID := range entryIDs {
Expand Down Expand Up @@ -97,7 +101,7 @@ func newArtifactImportCommand(opts *rootOptions) *cobra.Command {
CreatedAt: nowUTC(),
UpdatedAt: nowUTC(),
}
prepareArtifactReference(opts.storeRoot, &artifact, data, info)
prepareArtifactReference(rootPath, &artifact, data, info)
if artifact.IngestMode == model.ArtifactSnapshot {
artifact.Body = string(data)
}
Expand Down Expand Up @@ -140,11 +144,15 @@ func newArtifactShowCommand(opts *rootOptions) *cobra.Command {
if err != nil {
return err
}
rootPath, err := store.RootPath()
if err != nil {
return err
}
artifact, err := store.LoadArtifact(args[0])
if err != nil {
return err
}
artifact, changed, err := resolveArtifactReference(opts.storeRoot, artifact)
artifact, changed, err := resolveArtifactReference(rootPath, artifact)
if err != nil {
return err
}
Expand All @@ -170,14 +178,18 @@ func newArtifactListCommand(opts *rootOptions) *cobra.Command {
if err != nil {
return err
}
rootPath, err := store.RootPath()
if err != nil {
return err
}
artifacts, err := store.ListArtifacts()
if err != nil {
return err
}

items := make([]artifactListItem, 0, len(artifacts))
for _, artifact := range artifacts {
resolved, changed, err := resolveArtifactReference(opts.storeRoot, artifact)
resolved, changed, err := resolveArtifactReference(rootPath, artifact)
if err != nil {
return err
}
Expand Down
23 changes: 11 additions & 12 deletions internal/cli/artifact_reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import (

var errArtifactFound = errors.New("artifact candidate found")

func prepareArtifactReference(storeRoot string, artifact *model.Artifact, sourceData []byte, info os.FileInfo) {
workspaceRoot := filepath.Clean(filepath.Dir(storeRoot))
func prepareArtifactReference(rootPath string, artifact *model.Artifact, sourceData []byte, info os.FileInfo) {
searchRoots := uniquePaths(
filepath.Dir(artifact.SourceRef),
workspaceRoot,
rootPath,
)

artifact.ContentDigest = digestBytes(sourceData)
Expand All @@ -29,28 +28,28 @@ func prepareArtifactReference(storeRoot string, artifact *model.Artifact, source
}
}

func resolveArtifactReference(storeRoot string, artifact model.Artifact) (model.Artifact, bool, error) {
func resolveArtifactReference(rootPath string, artifact model.Artifact) (model.Artifact, bool, error) {
if artifact.IngestMode != model.ArtifactReference {
return artifact, false, nil
}

currentPath, err := findArtifactPath(storeRoot, artifact)
currentPath, err := findArtifactPath(rootPath, artifact)
if err != nil {
return artifact, false, err
}
if currentPath == "" {
return artifact, false, nil
}

updated, err := refreshReferenceArtifact(storeRoot, artifact, currentPath)
updated, err := refreshReferenceArtifact(rootPath, artifact, currentPath)
if err != nil {
return artifact, false, err
}

return updated, artifact.SourceRef != updated.SourceRef || artifact.ContentDigest != updated.ContentDigest, nil
}

func refreshReferenceArtifact(storeRoot string, artifact model.Artifact, path string) (model.Artifact, error) {
func refreshReferenceArtifact(rootPath string, artifact model.Artifact, path string) (model.Artifact, error) {
data, err := os.ReadFile(path)
if err != nil {
return artifact, err
Expand All @@ -62,16 +61,16 @@ func refreshReferenceArtifact(storeRoot string, artifact model.Artifact, path st

artifact.SourceRef = path
artifact.UpdatedAt = nowUTC()
prepareArtifactReference(storeRoot, &artifact, data, info)
prepareArtifactReference(rootPath, &artifact, data, info)
return artifact, nil
}

func findArtifactPath(storeRoot string, artifact model.Artifact) (string, error) {
func findArtifactPath(rootPath string, artifact model.Artifact) (string, error) {
if pathMatchesArtifactReference(artifact, artifact.SourceRef) {
return artifact.SourceRef, nil
}

for _, root := range searchRootsForArtifact(storeRoot, artifact) {
for _, root := range searchRootsForArtifact(rootPath, artifact) {
found, err := scanArtifactRoot(root, artifact)
if err != nil {
return "", err
Expand All @@ -84,12 +83,12 @@ func findArtifactPath(storeRoot string, artifact model.Artifact) (string, error)
return "", nil
}

func searchRootsForArtifact(storeRoot string, artifact model.Artifact) []string {
func searchRootsForArtifact(rootPath string, artifact model.Artifact) []string {
roots := make([]string, 0, 4)
if artifact.Locator != nil {
roots = append(roots, artifact.Locator.SearchRoots...)
}
roots = append(roots, filepath.Dir(artifact.SourceRef), filepath.Clean(filepath.Dir(storeRoot)))
roots = append(roots, filepath.Dir(artifact.SourceRef), filepath.Clean(rootPath))
return uniquePaths(roots...)
}

Expand Down
92 changes: 83 additions & 9 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ func NewRootCommand() *cobra.Command {
cmd.PersistentFlags().StringVar(
&opts.storeRoot,
"root",
defaultStoreRoot(),
"filesystem root for sandnote state",
"",
"filesystem root for sandnote state; defaults to the nearest initialized .sandnote",
)

cmd.AddCommand(
Expand All @@ -59,31 +59,105 @@ func NewRootCommand() *cobra.Command {
return cmd
}

func defaultStoreRoot() string {
func resolvePath(path string) (string, error) {
if path == "" {
return "", nil
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}

func currentWorkingDirectory() (string, error) {
wd, err := os.Getwd()
if err != nil {
return ".sandnote"
return "", err
}
return filepath.Clean(wd), nil
}

func resolveCommandStoreRoot(root string) (string, error) {
if root != "" {
return resolvePath(root)
}

wd, err := currentWorkingDirectory()
if err != nil {
return "", err
}
discovered, err := fsstore.DiscoverRoot(wd)
if err != nil {
return "", err
}
if discovered != "" {
return discovered, nil
}
return filepath.Join(wd, ".sandnote"), nil
}

func resolveInitRootPath(rootPath, storeRoot string) (string, error) {
if rootPath != "" {
return resolvePath(rootPath)
}
return filepath.Join(wd, ".sandnote")
if storeRoot != "" {
resolvedStoreRoot, err := resolvePath(storeRoot)
if err != nil {
return "", err
}
return filepath.Dir(resolvedStoreRoot), nil
}
return currentWorkingDirectory()
}

func resolveInitStoreRoot(storeRoot, rootPath string) (string, error) {
if storeRoot != "" {
return resolvePath(storeRoot)
}
return filepath.Join(rootPath, ".sandnote"), nil
}

func newInitCommand(opts *rootOptions) *cobra.Command {
return &cobra.Command{
var rootPath string

cmd := &cobra.Command{
Use: "init",
Short: "Initialize a filesystem-backed sandnote store",
Example: joinLines(
" sandnote init",
" sandnote init --root-path /path/to/repo",
" sandnote --root /tmp/demo/.sandnote init",
),
RunE: func(cmd *cobra.Command, args []string) error {
store := fsstore.New(opts.storeRoot)
if err := store.Init(); err != nil {
resolvedRootPath, err := resolveInitRootPath(rootPath, opts.storeRoot)
if err != nil {
return err
}
resolvedStoreRoot, err := resolveInitStoreRoot(opts.storeRoot, resolvedRootPath)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "initialized sandnote store at %s\n", store.Root())

existingRoot, err := fsstore.DiscoverRoot(resolvedRootPath)
if err != nil {
return err
}
if existingRoot != "" && existingRoot != resolvedStoreRoot {
return fmt.Errorf("sandnote store is already initialized at %s", existingRoot)
}

store := fsstore.New(resolvedStoreRoot)
if err := store.Init(resolvedRootPath); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "initialized sandnote store at %s for root path %s\n", store.Root(), resolvedRootPath)
return nil
},
}

cmd.Flags().StringVar(&rootPath, "root-path", "", "root path for notebook-relative discovery and artifact resolution")
return cmd
}

func addNotImplementedSubcommands(parent *cobra.Command, names ...string) {
Expand Down
107 changes: 107 additions & 0 deletions internal/cli/root_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package cli

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"github.com/sandbox0-ai/sandnote/internal/model"
"github.com/sandbox0-ai/sandnote/internal/store/fsstore"
)

func executeCLIInDir(t *testing.T, dir string, args ...string) (*bytes.Buffer, error) {
t.Helper()

t.Chdir(dir)

cmd := NewRootCommand()
output := &bytes.Buffer{}
cmd.SetOut(output)
cmd.SetErr(output)
cmd.SetArgs(args)
err := cmd.Execute()
return output, err
}

func TestInitPersistsRootPathAndRejectsNestedRepeat(t *testing.T) {
workspace := t.TempDir()
nested := filepath.Join(workspace, "services", "manager")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}

output, err := executeCLIInDir(t, workspace, "init")
if err != nil {
t.Fatalf("init error = %v\noutput=%s", err, output.String())
}

storeRoot := filepath.Join(workspace, ".sandnote")
if !strings.Contains(output.String(), "initialized sandnote store at "+storeRoot+" for root path "+workspace) {
t.Fatalf("unexpected init output:\n%s", output.String())
}

store := fsstore.New(storeRoot)
marker, err := store.LoadMarker()
if err != nil {
t.Fatalf("LoadMarker() error = %v", err)
}
if marker.RootPath != workspace {
t.Fatalf("expected root path %q, got %+v", workspace, marker)
}

_, err = executeCLIInDir(t, nested, "init")
if err == nil || !strings.Contains(err.Error(), "sandnote store is already initialized at "+storeRoot) {
t.Fatalf("expected nested repeat init to fail with existing store, got %v", err)
}
}

func TestCommandsDiscoverStoreAndUsePersistedRootPath(t *testing.T) {
workspace := t.TempDir()
nested := filepath.Join(workspace, "apps", "dashboard")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("MkdirAll(nested) error = %v", err)
}

if _, err := executeCLIInDir(t, workspace, "init"); err != nil {
t.Fatalf("init error = %v", err)
}

sourceDir := filepath.Join(workspace, "docs")
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
t.Fatalf("MkdirAll(sourceDir) error = %v", err)
}
oldPath := filepath.Join(sourceDir, "diagd-spec.md")
if err := os.WriteFile(oldPath, []byte("# diagd\ntrusted capability broker\n"), 0o644); err != nil {
t.Fatalf("WriteFile(oldPath) error = %v", err)
}

importPath := filepath.Join("..", "..", "docs", "diagd-spec.md")
if _, err := executeCLIInDir(t, nested, "artifact", "import", importPath, "--id", "art_diagd", "--mode", "reference"); err != nil {
t.Fatalf("artifact import error = %v", err)
}

archiveDir := filepath.Join(workspace, "archive")
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
t.Fatalf("MkdirAll(archiveDir) error = %v", err)
}
newPath := filepath.Join(archiveDir, "diagd-spec-renamed.md")
if err := os.Rename(oldPath, newPath); err != nil {
t.Fatalf("Rename() error = %v", err)
}

output, err := executeCLIInDir(t, nested, "artifact", "show", "art_diagd", "--json")
if err != nil {
t.Fatalf("artifact show error = %v\noutput=%s", err, output.String())
}

var got model.Artifact
if err := json.Unmarshal(output.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.SourceRef != newPath {
t.Fatalf("expected relocated artifact source_ref %q, got %+v", newPath, got)
}
}
8 changes: 6 additions & 2 deletions internal/cli/thread.go
Original file line number Diff line number Diff line change
Expand Up @@ -615,9 +615,13 @@ func newThreadTransitionCommand(opts *rootOptions) *cobra.Command {
}

func requireStore(root string) (*fsstore.Store, error) {
store := fsstore.New(root)
resolvedRoot, err := resolveCommandStoreRoot(root)
if err != nil {
return nil, err
}
store := fsstore.New(resolvedRoot)
if !store.Initialized() {
return nil, fmt.Errorf("sandnote store is not initialized at %s", root)
return nil, fmt.Errorf("sandnote store is not initialized at %s", resolvedRoot)
}
return store, nil
}
Expand Down
Loading
Loading