From 1dd2671a9d815006e52c83bffb353eef463ae9ad Mon Sep 17 00:00:00 2001 From: Rick Liu Date: Sat, 25 Jul 2026 21:57:00 +0100 Subject: [PATCH] feat(alias): user-level shorthand names for module sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An alias maps a short name to a module source plus default parameters, so `loom run git@github.com:foo/bar.git -p foo=bar` becomes `loom :bar`. A leading ":" marks an alias reference. Because the sigil makes the alias namespace disjoint from the subcommand namespace, `loom :bar` can dispatch to run without any bare name shadowing a subcommand — an unknown subcommand still gets cobra's usual "unknown command" error and suggestions. Top-level dispatch is an argv rewrite (`loom :bar` -> `loom run :bar`) rather than a root-level handler, so the alias form inherits the whole `loom run` flag set instead of having to redeclare it. Aliases are user-level only: one file at $LOOM_CONFIG_DIR/aliases.yaml (else /loom/aliases.yaml), with no repo-local file and no upward search, so changing branch or directory can never change what an alias resolves to. Resolution happens at the CLI boundary only — module.ResolveSource stays alias-blind so spec.modules[].source cannot depend on the operator's local aliases, keeping modules portable. Alias params are the lowest-precedence source, beneath --params-file and -p. Adds `loom alias add|list|remove` and specs/alias.md (AL1-AL13) with covering tests. `loom validate` and `loom bulk` deliberately do not accept references: neither consumes alias params, and bulk embeds its source into generated modules where a reference would violate AL8. Co-Authored-By: Claude Opus 5 --- cmd/alias.go | 139 +++++++++++++++++ cmd/alias_test.go | 300 ++++++++++++++++++++++++++++++++++++ cmd/diff.go | 9 +- cmd/root.go | 16 ++ cmd/run.go | 10 +- cmd/run_test.go | 3 + cmd/source.go | 59 +++++++ docs/.vitepress/config.mts | 1 + docs/reference/cli-alias.md | 147 ++++++++++++++++++ pkg/alias/alias.go | 209 +++++++++++++++++++++++++ pkg/alias/alias_test.go | 240 +++++++++++++++++++++++++++++ specs/alias.md | 153 ++++++++++++++++++ 12 files changed, 1283 insertions(+), 3 deletions(-) create mode 100644 cmd/alias.go create mode 100644 cmd/alias_test.go create mode 100644 cmd/source.go create mode 100644 docs/reference/cli-alias.md create mode 100644 pkg/alias/alias.go create mode 100644 pkg/alias/alias_test.go create mode 100644 specs/alias.md diff --git a/cmd/alias.go b/cmd/alias.go new file mode 100644 index 0000000..ce70da5 --- /dev/null +++ b/cmd/alias.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + prettylog "github.com/rickliujh/loom/internal/log" + "github.com/rickliujh/loom/pkg/alias" + "github.com/spf13/cobra" +) + +var ( + aliasParams []string + aliasForce bool +) + +var aliasCmd = &cobra.Command{ + Use: "alias", + Short: "Manage shorthand names for module sources", + Long: `Manage user-level aliases for module sources. + +An alias maps a short name to a module source and a set of default parameters, +so a long invocation: + + loom run git@github.com:foo/bar.git -p foo=bar -p something=anotherthing + +becomes: + + loom :bar + +Aliases are stored per-user and are never read from a repository, so switching +branches can never change what an alias resolves to.`, +} + +var aliasAddCmd = &cobra.Command{ + Use: "add ", + Short: "Create an alias for a module source", + Long: `Create an alias for a module source. + +The source accepts the same forms as ` + "`loom run`" + `: a local path, a git URL, or a +git URL with the //subdir suffix. Parameters given with -p become the alias's +default parameters, overridable at run time.`, + Example: ` loom alias add bar git@github.com:foo/bar.git//modules/svc -p foo=bar + loom :bar`, + Args: cobra.ExactArgs(2), + RunE: runAliasAdd, +} + +var aliasRemoveCmd = &cobra.Command{ + Use: "remove ", + Aliases: []string{"rm"}, + Short: "Delete an alias", + Args: cobra.ExactArgs(1), + RunE: runAliasRemove, +} + +var aliasListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List defined aliases", + Args: cobra.NoArgs, + RunE: runAliasList, +} + +func init() { + aliasAddCmd.Flags().StringArrayVarP(&aliasParams, "param", "p", nil, "Default parameter in key=value format (can be repeated)") + aliasAddCmd.Flags().BoolVar(&aliasForce, "force", false, "Replace an existing alias of the same name") + aliasCmd.AddCommand(aliasAddCmd, aliasRemoveCmd, aliasListCmd) + rootCmd.AddCommand(aliasCmd) +} + +func runAliasAdd(cmd *cobra.Command, args []string) error { + name, source := args[0], args[1] + + params, err := parseParams(aliasParams, "") + if err != nil { + return err + } + + f, err := alias.Load() + if err != nil { + return err + } + def := &alias.Def{Source: source} + if len(params) > 0 { + def.Params = params + } + if err := f.Add(name, def, aliasForce); err != nil { + return err + } + if err := f.Save(); err != nil { + return err + } + + prettylog.Successf(os.Stderr, "alias %q → %s", strings.TrimPrefix(name, alias.Ref), source) + fmt.Fprintf(os.Stderr, " run it with: loom :%s\n", strings.TrimPrefix(name, alias.Ref)) + return nil +} + +func runAliasRemove(cmd *cobra.Command, args []string) error { + f, err := alias.Load() + if err != nil { + return err + } + if err := f.Remove(args[0]); err != nil { + return err + } + if err := f.Save(); err != nil { + return err + } + prettylog.Successf(os.Stderr, "removed alias %q", strings.TrimPrefix(args[0], alias.Ref)) + return nil +} + +func runAliasList(cmd *cobra.Command, args []string) error { + f, err := alias.Load() + if err != nil { + return err + } + + names := f.Names() + if len(names) == 0 { + fmt.Fprintf(os.Stderr, "no aliases defined (%s)\n", f.Path()) + fmt.Fprintln(os.Stderr, "add one with: loom alias add ") + return nil + } + + out := cmd.OutOrStdout() + for _, name := range names { + def := f.Aliases[name] + fmt.Fprintf(out, ":%s\n", name) + fmt.Fprintf(out, " source: %s\n", def.Source) + for _, k := range sortedKeys(def.Params) { + fmt.Fprintf(out, " param: %s=%s\n", k, def.Params[k]) + } + } + return nil +} diff --git a/cmd/alias_test.go b/cmd/alias_test.go new file mode 100644 index 0000000..4d01416 --- /dev/null +++ b/cmd/alias_test.go @@ -0,0 +1,300 @@ +package cmd + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rickliujh/loom/pkg/module" +) + +// aliasConfigDir points the alias file at a temp dir for the duration of a test. +func aliasConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LOOM_CONFIG_DIR", dir) + return dir +} + +func writeAliasFile(t *testing.T, dir, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "aliases.yaml"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +// newFilesModule writes a module that copies templates/ into the target dir, +// naming the emitted file after the "greeting" param so a test can read back +// which value won. +func newFilesModule(t *testing.T, dir string) { + t.Helper() + srcDir := filepath.Join(dir, "templates") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(srcDir, "out.txt"), []byte("{{ .greeting }}"), 0o644); err != nil { + t.Fatal(err) + } + writeLoomYAML(t, dir, ` +apiVersion: loom.rickliujh.github.io/v1beta1 +kind: Loom +metadata: + name: alias-test +spec: + params: + - name: greeting + default: from-module + operations: + - name: write-files + newFiles: + source: "templates" + dest: "" +`) +} + +// AL6: `loom :bar` rewrites to `loom run :bar`; anything else is untouched, so +// unknown subcommands still reach cobra's own dispatch. +func TestAlias_AL6_DispatchArgs(t *testing.T) { + tests := []struct { + name string + in []string + want []string + }{ + {"bare alias defaults to run", []string{":bar"}, []string{"run", ":bar"}}, + {"alias keeps trailing flags", []string{":bar", "-p", "a=b"}, []string{"run", ":bar", "-p", "a=b"}}, + {"explicit run is untouched", []string{"run", ":bar"}, []string{"run", ":bar"}}, + {"other subcommands untouched", []string{"diff", ":bar"}, []string{"diff", ":bar"}}, + {"typo untouched", []string{"rnu"}, []string{"rnu"}}, + {"path untouched", []string{"run", "./mod"}, []string{"run", "./mod"}}, + {"no args", nil, nil}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dispatchArgs(tt.in) + if strings.Join(got, " ") != strings.Join(tt.want, " ") { + t.Errorf("dispatchArgs(%v) = %v, want %v", tt.in, got, tt.want) + } + }) + } +} + +// AL6: a typo is still reported as an unknown command rather than swallowed by +// alias handling — the property the ":" sigil buys. +func TestAlias_AL6_TypoIsUnknownCommand(t *testing.T) { + resetFlags() + rootCmd.SetArgs(dispatchArgs([]string{"rnu"})) + err := rootCmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown command") { + t.Errorf("expected an unknown-command error, got %v", err) + } +} + +// AL7: `loom run :bar` resolves the alias to its source and runs it. +func TestAlias_AL7_RunAcceptsAliasRef(t *testing.T) { + resetFlags() + cfgDir := aliasConfigDir(t) + + moduleDir := t.TempDir() + targetDir := t.TempDir() + newFilesModule(t, moduleDir) + writeAliasFile(t, cfgDir, "aliases:\n bar:\n source: "+moduleDir+"\n") + + rootCmd.SetArgs(dispatchArgs([]string{":bar", "--target-path", targetDir})) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(filepath.Join(targetDir, "out.txt")); err != nil { + t.Error("expected the aliased module to run and write out.txt") + } +} + +// AL5/AL7: an unknown alias reports the alias, and never falls through to a +// clone attempt. +func TestAlias_AL5_RunUnknownAliasErrors(t *testing.T) { + resetFlags() + aliasConfigDir(t) + + rootCmd.SetArgs(dispatchArgs([]string{":nope", "--target-path", t.TempDir()})) + err := rootCmd.Execute() + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), `unknown alias "nope"`) { + t.Errorf("expected an unknown-alias error, got %q", err) + } + if strings.Contains(err.Error(), "cloning") { + t.Errorf("alias miss fell through to a clone: %q", err) + } +} + +// AL8: the module executor does not consult the alias file, so a module's +// child source can never depend on the operator's local aliases. +func TestAlias_AL8_ExecutorDoesNotResolveAliases(t *testing.T) { + cfgDir := aliasConfigDir(t) + moduleDir := t.TempDir() + newFilesModule(t, moduleDir) + writeAliasFile(t, cfgDir, "aliases:\n bar:\n source: "+moduleDir+"\n") + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + _, cleanup, err := module.ResolveSource(":bar", ".", logger) + if cleanup != nil { + cleanup() + } + if err == nil { + t.Fatal("ResolveSource should not resolve alias references") + } + if strings.Contains(err.Error(), "unknown alias") { + t.Errorf("ResolveSource consulted the alias file: %q", err) + } +} + +// AL9: alias params sit beneath --params-file and -p. +func TestAlias_AL9_ParamPrecedence(t *testing.T) { + cfgDir := aliasConfigDir(t) + paramsFile := filepath.Join(t.TempDir(), "params.yaml") + if err := os.WriteFile(paramsFile, []byte("greeting: from-file\n"), 0o644); err != nil { + t.Fatal(err) + } + writeAliasFile(t, cfgDir, "aliases:\n bar:\n source: ./x\n params:\n greeting: from-alias\n") + + tests := []struct { + name string + args []string + want string + }{ + {"alias only", nil, "from-alias"}, + {"params-file beats alias", []string{"--params-file", paramsFile}, "from-file"}, + {"-p beats alias", []string{"-p", "greeting=from-flag"}, "from-flag"}, + {"-p beats params-file and alias", []string{"--params-file", paramsFile, "-p", "greeting=from-flag"}, "from-flag"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resetFlags() + moduleDir := t.TempDir() + targetDir := t.TempDir() + newFilesModule(t, moduleDir) + writeAliasFile(t, cfgDir, "aliases:\n bar:\n source: "+moduleDir+"\n params:\n greeting: from-alias\n") + + args := append([]string{":bar", "--target-path", targetDir}, tt.args...) + rootCmd.SetArgs(dispatchArgs(args)) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + got, err := os.ReadFile(filepath.Join(targetDir, "out.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != tt.want { + t.Errorf("greeting = %q, want %q", got, tt.want) + } + }) + } +} + +// AL9: a module default still applies when no alias param supplies a value. +func TestAlias_AL9_ModuleDefaultBeneathAlias(t *testing.T) { + resetFlags() + cfgDir := aliasConfigDir(t) + + moduleDir := t.TempDir() + targetDir := t.TempDir() + newFilesModule(t, moduleDir) + writeAliasFile(t, cfgDir, "aliases:\n bar:\n source: "+moduleDir+"\n") + + rootCmd.SetArgs(dispatchArgs([]string{":bar", "--target-path", targetDir})) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + got, err := os.ReadFile(filepath.Join(targetDir, "out.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "from-module" { + t.Errorf("greeting = %q, want %q", got, "from-module") + } +} + +// AL12: list reports every alias sorted by name, with source and params. +func TestAlias_AL12_List(t *testing.T) { + resetFlags() + cfgDir := aliasConfigDir(t) + writeAliasFile(t, cfgDir, `aliases: + zeta: + source: ./z + bar: + source: git@github.com:foo/bar.git + params: + foo: bar +`) + + out := captureStdout(t, func() { + rootCmd.SetArgs([]string{"alias", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, ":bar") || !strings.Contains(out, "git@github.com:foo/bar.git") { + t.Errorf("list omitted the bar alias:\n%s", out) + } + if !strings.Contains(out, "foo=bar") { + t.Errorf("list omitted params:\n%s", out) + } + if strings.Index(out, ":bar") > strings.Index(out, ":zeta") { + t.Errorf("list is not sorted by name:\n%s", out) + } +} + +// AL10/AL12: add writes an entry that list then reports and run can resolve. +func TestAlias_AL10_AddThenList(t *testing.T) { + resetFlags() + aliasConfigDir(t) + + rootCmd.SetArgs([]string{"alias", "add", "bar", "git@github.com:foo/bar.git", "-p", "foo=bar"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := captureStdout(t, func() { + rootCmd.SetArgs([]string{"alias", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + if !strings.Contains(out, ":bar") || !strings.Contains(out, "foo=bar") { + t.Errorf("added alias not listed:\n%s", out) + } + + // A second add without --force is refused. + rootCmd.SetArgs([]string{"alias", "add", "bar", "./other"}) + if err := rootCmd.Execute(); err == nil { + t.Error("expected an already-exists error") + } +} + +// AL11: remove deletes the entry through the CLI. +func TestAlias_AL11_RemoveCmd(t *testing.T) { + resetFlags() + cfgDir := aliasConfigDir(t) + writeAliasFile(t, cfgDir, "aliases:\n bar:\n source: ./bar\n") + + rootCmd.SetArgs([]string{"alias", "remove", "bar"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := captureStdout(t, func() { + rootCmd.SetArgs([]string{"alias", "list"}) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + if strings.Contains(out, ":bar") { + t.Errorf("alias still listed after remove:\n%s", out) + } +} diff --git a/cmd/diff.go b/cmd/diff.go index 8a5f04a..31f8872 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -63,7 +63,14 @@ func runDiff(cmd *cobra.Command, args []string) error { source = args[0] } - paramMap, err := parseParams(diffParams, diffParamsFile) + // Expand a ":name" alias reference before resolving (AL4). + source, aliasParams, err := resolveSourceArg(source) + if err != nil { + return err + } + + // Alias params sit beneath --params-file and -p (AL9). + paramMap, err := parseParamsWithDefaults(aliasParams, diffParams, diffParamsFile) if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 1f51a61..04cd9df 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "os" prettylog "github.com/rickliujh/loom/internal/log" + "github.com/rickliujh/loom/pkg/alias" "github.com/spf13/cobra" ) @@ -28,12 +29,27 @@ var rootCmd = &cobra.Command{ // Execute runs the root command. func Execute() { + rootCmd.SetArgs(dispatchArgs(os.Args[1:])) if err := rootCmd.Execute(); err != nil { prettylog.Failuref(os.Stderr, "%v", err) os.Exit(1) } } +// dispatchArgs rewrites `loom :bar ...` to `loom run :bar ...` so a bare alias +// reference defaults to run (AL6). Only a leading ":" triggers the rewrite, so +// every other argument keeps normal cobra dispatch — an unknown subcommand +// still reports "unknown command" with its usual suggestions. +// +// The rewrite is what lets the alias form accept the full run flag set: the +// flags live on runCmd, and a root-level handler would have to redeclare them. +func dispatchArgs(args []string) []string { + if len(args) > 0 && alias.IsRef(args[0]) { + return append([]string{runCmd.Name()}, args...) + } + return args +} + func init() { // SilenceUsage suppresses help text on runtime errors; still show it for flag misuse. rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { diff --git a/cmd/run.go b/cmd/run.go index 00b2c59..a07f28c 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -51,6 +51,12 @@ func runModule(cmd *cobra.Command, args []string) error { source = args[0] } + // Expand a ":name" alias reference before resolving (AL4). + source, aliasParams, err := resolveSourceArg(source) + if err != nil { + return err + } + // Resolve source — handles git URLs, //subdir, and local paths. moduleDir, cleanup, err := module.ResolveSource(source, ".", logger) if err != nil { @@ -60,8 +66,8 @@ func runModule(cmd *cobra.Command, args []string) error { defer cleanup() } - // Parse parameters. - paramMap, err := parseParams(params, paramsFile) + // Parse parameters — alias params sit beneath --params-file and -p (AL9). + paramMap, err := parseParamsWithDefaults(aliasParams, params, paramsFile) if err != nil { return err } diff --git a/cmd/run_test.go b/cmd/run_test.go index 5d101cf..8aa0d01 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -60,6 +60,9 @@ func resetFlags() { diffParamsFile = "" diffAuthor = "" diffEmail = "" + + aliasParams = nil + aliasForce = false } // L1: --local-run without --target-path errors (with target spec). diff --git a/cmd/source.go b/cmd/source.go new file mode 100644 index 0000000..e561aad --- /dev/null +++ b/cmd/source.go @@ -0,0 +1,59 @@ +package cmd + +import ( + "sort" + + "github.com/rickliujh/loom/pkg/alias" +) + +// resolveSourceArg expands an alias reference (":name") into the module source +// it names and the alias's default parameters. Any other argument is returned +// unchanged, so local paths and git URLs keep their existing behavior (AL4). +// +// This runs at the CLI boundary only. module.ResolveSource is deliberately not +// alias-aware: it also resolves spec.modules[].source, and a module whose child +// sources depended on the operator's personal alias file would not be portable +// (AL8). +func resolveSourceArg(source string) (string, map[string]string, error) { + if !alias.IsRef(source) { + return source, nil, nil + } + + name, err := alias.ParseRef(source) + if err != nil { + return "", nil, err + } + f, err := alias.Load() + if err != nil { + return "", nil, err + } + def, err := f.Resolve(name) + if err != nil { + return "", nil, err + } + return def.Source, def.Params, nil +} + +// parseParamsWithDefaults merges parameters in ascending precedence: defaults +// (an alias's params), then the params file, then -p flags (AL9). +func parseParamsWithDefaults(defaults map[string]string, cliParams []string, paramsFile string) (map[string]string, error) { + result, err := parseParams(cliParams, paramsFile) + if err != nil { + return nil, err + } + for k, v := range defaults { + if _, set := result[k]; !set { + result[k] = v + } + } + return result, nil +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 4bbc8c3..6075895 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -95,6 +95,7 @@ export default defineConfig({ { text: 'loom generate', link: '/reference/cli-generate' }, { text: 'loom bulk', link: '/reference/cli-bulk' }, { text: 'loom validate', link: '/reference/cli-validate' }, + { text: 'loom alias', link: '/reference/cli-alias' }, ], }, ], diff --git a/docs/reference/cli-alias.md b/docs/reference/cli-alias.md new file mode 100644 index 0000000..60b4432 --- /dev/null +++ b/docs/reference/cli-alias.md @@ -0,0 +1,147 @@ +# loom alias + +Give a module source a short name, so a long invocation: + +```bash +loom run git@github.com:foo/bar.git//modules/svc -p foo=bar -p something=anotherthing +``` + +becomes: + +```bash +loom :bar +``` + +An alias stores a module source plus a set of default parameters. Any argument +beginning with `:` is an alias reference; everything else is a path or git URL +as before. + +## Quick start + +```bash +loom alias add bar git@github.com:foo/bar.git//modules/svc -p foo=bar +loom :bar # run it +loom :bar -p foo=other # override a param +loom diff :bar # preview it +``` + +`loom :bar` is shorthand for `loom run :bar`, so every `loom run` flag works +unchanged. Other commands take the reference in the source position. + +## Subcommands + +| Command | Description | +|---------|-------------| +| `loom alias add ` | Create an alias | +| `loom alias list` (`ls`) | List defined aliases | +| `loom alias remove ` (`rm`) | Delete an alias | + +### `loom alias add` + +| Flag | Description | +|------|-------------| +| `-p, --param key=value` | Default parameter (can be repeated) | +| `--force` | Replace an existing alias of the same name | + +The source accepts the same forms as `loom run`: a local path, a git URL, or a +git URL with the `//subdir` suffix. Adding a name that already exists is an +error unless `--force` is given. + +Alias names may contain letters, digits, `.`, `_`, and `-`, and must start with +a letter or digit. They may not contain `:`, `/`, `=`, or whitespace — that is +what keeps a name unambiguous against a path, a git URL, and a `key=value` +argument. + +## Where aliases live + +Aliases are **user-level** configuration, stored in a single file: + +``` +$LOOM_CONFIG_DIR/aliases.yaml # if LOOM_CONFIG_DIR is set +/loom/aliases.yaml # otherwise (e.g. ~/.config/loom/aliases.yaml) +``` + +There is no repository-local alias file and no search up the directory tree, so +changing branch or directory can never change what an alias resolves to. + +The file is managed by `loom alias`, but it is plain YAML and can be edited by +hand: + +```yaml +aliases: + bar: + source: git@github.com:foo/bar.git//modules/svc + params: + foo: bar + something: anotherthing +``` + +Define one alias per parameter set to get named variants: + +```yaml +aliases: + deploy-prod: + source: git@github.com:foo/deploy.git + params: {env: prod, region: us-east-1} + deploy-dev: + source: git@github.com:foo/deploy.git + params: {env: dev, region: us-west-2} +``` + +```bash +loom :deploy-prod +``` + +## Parameter precedence + +An alias's params are the lowest-precedence source, so anything you pass at run +time wins: + +| Precedence | Source | +|-----------|--------| +| 1 (lowest) | Alias `params` | +| 2 | `--params-file` | +| 3 (highest) | `-p key=value` | + +Module-level `spec.params` defaults still apply beneath all three — an alias +param supplies a value, so it suppresses the module's default exactly as `-p` +would. + +## Aliases are not module configuration + +Aliases are resolved by the CLI only. A module's own `spec.modules[].source` is +never resolved against the alias file: + +```yaml +spec: + modules: + - name: child + source: ":bar" # not an alias — fails to resolve +``` + +This is deliberate. A module whose child sources depended on whichever aliases +the operator happened to have defined would not be portable between machines. + +## Which commands accept a reference + +`loom run` and `loom diff` accept `:` in the module-source position. +`loom validate` and `loom bulk` do not — `bulk` embeds the source it is given +into the module it generates, where an alias reference would not resolve for +anyone else. + +## Errors + +| Condition | Error | +|-----------|-------| +| Bare `:` | `empty alias name: expected :` | +| Alias not defined | `unknown alias "" (looked in )` | +| Invalid name | `invalid alias name "": must match [a-zA-Z0-9][a-zA-Z0-9._-]*` | +| `add` over an existing name | `alias "" already exists (use --force to replace)` | + +An unknown alias is never retried as a git URL, so a mistyped alias reports a +mistyped alias rather than a failed clone. + +## See also + +- [Alias specification](https://github.com/rickliujh/loom/blob/main/specs/alias.md) +- [loom run](/reference/cli-run) diff --git a/pkg/alias/alias.go b/pkg/alias/alias.go new file mode 100644 index 0000000..5f8a902 --- /dev/null +++ b/pkg/alias/alias.go @@ -0,0 +1,209 @@ +// Package alias implements user-level shorthand names for module sources. +// +// An alias maps a short name to a module source plus a set of default +// parameters, so `loom :bar` stands in for +// `loom run git@github.com:foo/bar.git -p foo=bar`. +// +// Aliases are user configuration, not module configuration: they are resolved +// at the CLI boundary only. The module executor never consults them (AL8), so +// a module stays portable between operators regardless of what either has +// defined locally. +package alias + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// Ref is the prefix marking an argument as an alias reference (AL4). +const Ref = ":" + +// nameRe is the permitted alias name grammar (AL3). Excluding ":", "/", "=", +// and whitespace is what keeps a name unambiguous against a path, a git URL, +// and a key=value argument. +var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`) + +// Def is a single alias entry. +type Def struct { + Source string `yaml:"source"` + Params map[string]string `yaml:"params,omitempty"` +} + +// File is the parsed alias file. +type File struct { + Aliases map[string]*Def `yaml:"aliases"` + + // path records where this file was loaded from, so errors can name it. + path string +} + +// Path returns the alias file location (AL1). LOOM_CONFIG_DIR overrides the +// user config dir, which keeps the path injectable for tests. +func Path() (string, error) { + if dir := os.Getenv("LOOM_CONFIG_DIR"); dir != "" { + return filepath.Join(dir, "aliases.yaml"), nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locating user config dir: %w", err) + } + return filepath.Join(dir, "loom", "aliases.yaml"), nil +} + +// IsRef reports whether arg is an alias reference (AL4). +func IsRef(arg string) bool { + return strings.HasPrefix(arg, Ref) +} + +// ParseRef returns the alias name from a reference argument. +func ParseRef(arg string) (string, error) { + name := strings.TrimPrefix(arg, Ref) + if name == "" { + return "", fmt.Errorf("empty alias name: expected :") + } + return name, nil +} + +// Load reads the alias file. A missing file yields an empty set, not an +// error (AL2) — only referencing a specific alias can fail on that. +func Load() (*File, error) { + path, err := Path() + if err != nil { + return nil, err + } + return LoadFrom(path) +} + +// LoadFrom reads the alias file at an explicit path. +func LoadFrom(path string) (*File, error) { + f := &File{Aliases: map[string]*Def{}, path: path} + + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return f, nil + } + if err != nil { + return nil, fmt.Errorf("reading alias file: %w", err) + } + + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(f); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("parsing %s: %w", path, err) + } + if f.Aliases == nil { + f.Aliases = map[string]*Def{} + } + f.path = path + + // Validate on the way in so a malformed file fails at the file, not later + // at a confusing clone error. + for name, def := range f.Aliases { + if !nameRe.MatchString(name) { + return nil, fmt.Errorf("invalid alias name %q in %s: must match [a-zA-Z0-9][a-zA-Z0-9._-]*", name, path) + } + if def == nil || def.Source == "" { + return nil, fmt.Errorf("alias %q in %s has no source", name, path) + } + } + + return f, nil +} + +// Path returns where this file was loaded from. +func (f *File) Path() string { return f.path } + +// Resolve looks up an alias by name. An unknown alias is an error rather than +// a fallthrough to the git-URL path (AL5). +func (f *File) Resolve(name string) (*Def, error) { + def, ok := f.Aliases[name] + if !ok { + return nil, fmt.Errorf("unknown alias %q (looked in %s)", name, f.path) + } + return def, nil +} + +// Names returns every alias name in sorted order. +func (f *File) Names() []string { + names := make([]string, 0, len(f.Aliases)) + for name := range f.Aliases { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// Add inserts an alias. Without force, an existing name is an error so an +// alias is never silently replaced (AL10). +func (f *File) Add(name string, def *Def, force bool) error { + name = strings.TrimPrefix(name, Ref) + if !nameRe.MatchString(name) { + return fmt.Errorf("invalid alias name %q: must match [a-zA-Z0-9][a-zA-Z0-9._-]*", name) + } + if def.Source == "" { + return fmt.Errorf("alias %q has no source", name) + } + if _, exists := f.Aliases[name]; exists && !force { + return fmt.Errorf("alias %q already exists (use --force to replace)", name) + } + f.Aliases[name] = def + return nil +} + +// Remove deletes an alias (AL11). +func (f *File) Remove(name string) error { + name = strings.TrimPrefix(name, Ref) + if _, ok := f.Aliases[name]; !ok { + return fmt.Errorf("unknown alias %q (looked in %s)", name, f.path) + } + delete(f.Aliases, name) + return nil +} + +// Save writes the alias file, creating its parent directory as needed. The +// write goes to a temp file in the same directory and is renamed into place so +// a failure can never leave a partially written alias file (AL13). +func (f *File) Save() error { + if err := os.MkdirAll(filepath.Dir(f.path), 0o755); err != nil { + return fmt.Errorf("creating config dir: %w", err) + } + + var buf bytes.Buffer + buf.WriteString("# loom aliases — managed by `loom alias`.\n") + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(f); err != nil { + return fmt.Errorf("encoding alias file: %w", err) + } + if err := enc.Close(); err != nil { + return fmt.Errorf("encoding alias file: %w", err) + } + + tmp, err := os.CreateTemp(filepath.Dir(f.path), ".aliases-*.yaml") + if err != nil { + return fmt.Errorf("creating temp file: %w", err) + } + tmpName := tmp.Name() + defer os.Remove(tmpName) + + if _, err := tmp.Write(buf.Bytes()); err != nil { + tmp.Close() + return fmt.Errorf("writing alias file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("writing alias file: %w", err) + } + if err := os.Rename(tmpName, f.path); err != nil { + return fmt.Errorf("writing alias file: %w", err) + } + return nil +} diff --git a/pkg/alias/alias_test.go b/pkg/alias/alias_test.go new file mode 100644 index 0000000..547bc6a --- /dev/null +++ b/pkg/alias/alias_test.go @@ -0,0 +1,240 @@ +package alias + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// withConfigDir points the alias file at a temp dir for the duration of a test. +func withConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LOOM_CONFIG_DIR", dir) + return dir +} + +func writeAliases(t *testing.T, dir, content string) string { + t.Helper() + path := filepath.Join(dir, "aliases.yaml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +// AL1: LOOM_CONFIG_DIR overrides the location; otherwise it is +// /loom/aliases.yaml. +func TestAlias_AL1_Path(t *testing.T) { + dir := withConfigDir(t) + got, err := Path() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dir, "aliases.yaml"); got != want { + t.Errorf("Path() = %q, want %q", got, want) + } + + t.Setenv("LOOM_CONFIG_DIR", "") + got, err = Path() + if err != nil { + t.Fatal(err) + } + if want := filepath.Join("loom", "aliases.yaml"); !strings.HasSuffix(got, want) { + t.Errorf("Path() = %q, want suffix %q", got, want) + } +} + +// AL2: a missing alias file is an empty set, not an error. +func TestAlias_AL2_MissingFileIsEmpty(t *testing.T) { + withConfigDir(t) // nothing written + + f, err := Load() + if err != nil { + t.Fatalf("missing alias file should not error, got %v", err) + } + if len(f.Names()) != 0 { + t.Errorf("expected no aliases, got %v", f.Names()) + } +} + +// AL3: names may not contain ":", "/", "=", or whitespace — the property that +// keeps them unambiguous against paths, git URLs, and key=value args. +func TestAlias_AL3_NameGrammar(t *testing.T) { + withConfigDir(t) + + valid := []string{"bar", "my-module", "svc.onboard", "a_b", "x1"} + invalid := []string{"", "-lead", "has space", "a/b", "a:b", "a=b", "git@x.com:y.git"} + + for _, name := range valid { + f, _ := Load() + if err := f.Add(name, &Def{Source: "./x"}, false); err != nil { + t.Errorf("Add(%q) rejected a valid name: %v", name, err) + } + } + for _, name := range invalid { + f, _ := Load() + if err := f.Add(name, &Def{Source: "./x"}, false); err == nil { + t.Errorf("Add(%q) accepted an invalid name", name) + } + } + + // An invalid name in the file is rejected at load time, not at use time. + dir := os.Getenv("LOOM_CONFIG_DIR") + writeAliases(t, dir, "aliases:\n \"a/b\":\n source: ./x\n") + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "invalid alias name") { + t.Errorf("expected invalid-name load error, got %v", err) + } +} + +// AL4: a leading ":" marks an alias reference; a bare ":" is an error. +func TestAlias_AL4_RefSyntax(t *testing.T) { + for _, arg := range []string{":bar", ":"} { + if !IsRef(arg) { + t.Errorf("IsRef(%q) = false, want true", arg) + } + } + for _, arg := range []string{"bar", "./bar", "git@github.com:foo/bar.git", "https://x/y//sub"} { + if IsRef(arg) { + t.Errorf("IsRef(%q) = true, want false", arg) + } + } + + name, err := ParseRef(":bar") + if err != nil || name != "bar" { + t.Errorf("ParseRef(\":bar\") = (%q, %v), want (\"bar\", nil)", name, err) + } + if _, err := ParseRef(":"); err == nil { + t.Error("ParseRef(\":\") should error on an empty name") + } +} + +// AL5: an unknown alias errors naming the file — it is never retried as a git +// URL, so a typo reports a typo rather than a failed clone. +func TestAlias_AL5_UnknownAlias(t *testing.T) { + dir := withConfigDir(t) + path := writeAliases(t, dir, "aliases:\n bar:\n source: ./bar\n") + + f, err := Load() + if err != nil { + t.Fatal(err) + } + _, err = f.Resolve("bra") + if err == nil { + t.Fatal("expected unknown-alias error") + } + if !strings.Contains(err.Error(), `unknown alias "bra"`) || !strings.Contains(err.Error(), path) { + t.Errorf("error should name the alias and the file, got %q", err) + } +} + +// AL10: add refuses to silently replace an existing alias. +func TestAlias_AL10_AddNoClobber(t *testing.T) { + withConfigDir(t) + + f, _ := Load() + if err := f.Add("bar", &Def{Source: "./one"}, false); err != nil { + t.Fatal(err) + } + if err := f.Add("bar", &Def{Source: "./two"}, false); err == nil { + t.Fatal("expected an already-exists error") + } + if f.Aliases["bar"].Source != "./one" { + t.Errorf("source changed despite the error: %q", f.Aliases["bar"].Source) + } + if err := f.Add("bar", &Def{Source: "./two"}, true); err != nil { + t.Fatalf("--force should replace: %v", err) + } + if f.Aliases["bar"].Source != "./two" { + t.Errorf("force did not replace, got %q", f.Aliases["bar"].Source) + } + + // A leading ":" on the name is accepted and normalized away. + if err := f.Add(":colon", &Def{Source: "./x"}, false); err != nil { + t.Fatal(err) + } + if _, ok := f.Aliases["colon"]; !ok { + t.Errorf("name not normalized, got %v", f.Names()) + } +} + +// AL11: remove deletes an entry; removing an unknown alias errors. +func TestAlias_AL11_Remove(t *testing.T) { + dir := withConfigDir(t) + writeAliases(t, dir, "aliases:\n bar:\n source: ./bar\n") + + f, _ := Load() + if err := f.Remove("bar"); err != nil { + t.Fatal(err) + } + if len(f.Names()) != 0 { + t.Errorf("expected empty after remove, got %v", f.Names()) + } + if err := f.Remove("bar"); err == nil { + t.Error("removing an unknown alias should error") + } +} + +// AL13: a save round-trips through the file and leaves no temp files behind. +func TestAlias_AL13_SaveRoundTrip(t *testing.T) { + dir := withConfigDir(t) + + f, _ := Load() + if err := f.Add("bar", &Def{ + Source: "git@github.com:foo/bar.git//modules/svc", + Params: map[string]string{"foo": "bar", "something": "anotherthing"}, + }, false); err != nil { + t.Fatal(err) + } + if err := f.Save(); err != nil { + t.Fatal(err) + } + + reloaded, err := Load() + if err != nil { + t.Fatal(err) + } + def, err := reloaded.Resolve("bar") + if err != nil { + t.Fatal(err) + } + if def.Source != "git@github.com:foo/bar.git//modules/svc" { + t.Errorf("source did not round-trip: %q", def.Source) + } + if def.Params["foo"] != "bar" || def.Params["something"] != "anotherthing" { + t.Errorf("params did not round-trip: %v", def.Params) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if e.Name() != "aliases.yaml" { + t.Errorf("save left a stray file behind: %s", e.Name()) + } + } +} + +// An entry without a source is rejected at load time rather than producing a +// confusing empty-source clone later. +func TestAlias_SourceRequired(t *testing.T) { + dir := withConfigDir(t) + writeAliases(t, dir, "aliases:\n bar:\n params:\n a: b\n") + + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "has no source") { + t.Errorf("expected a missing-source error, got %v", err) + } +} + +// Unknown fields in the alias file are rejected, so a typo is reported at the +// field rather than silently ignored. +func TestAlias_UnknownFieldRejected(t *testing.T) { + dir := withConfigDir(t) + writeAliases(t, dir, "aliases:\n bar:\n source: ./bar\n sorce: ./typo\n") + + if _, err := Load(); err == nil { + t.Error("expected an unknown-field error") + } +} diff --git a/specs/alias.md b/specs/alias.md new file mode 100644 index 0000000..57cd1b4 --- /dev/null +++ b/specs/alias.md @@ -0,0 +1,153 @@ +# Loom Alias — Behavioral Specification + +An alias is a short, user-defined name for a module source plus a set of default +parameters. It exists purely to shorten invocation: `loom :bar` in place of +`loom run git@github.com:foo/bar.git -p foo=bar`. + +Aliases are **user-level configuration**, never module configuration. They are +resolved at the CLI boundary only, so a module's own `spec.modules[].source` is +never affected by the aliases a particular operator happens to have defined. + +--- + +## Alias File + +Aliases live in a single user-level YAML file: + +```yaml +aliases: + bar: + source: git@github.com:foo/bar.git//modules/svc + params: + foo: bar + something: anotherthing +``` + +The value of an entry is an `AliasDef`: a required `source` and an optional +`params` map. Unknown fields are rejected. + +### Behaviors + +#### AL1: Alias file location + +The file path is `$LOOM_CONFIG_DIR/aliases.yaml` when `LOOM_CONFIG_DIR` is set, +otherwise `/loom/aliases.yaml`. There is no repository-local +alias file and no search up the directory tree: switching branches or changing +directory can never change what an alias resolves to. + +#### AL2: Missing file is an empty alias set + +A missing alias file is not an error. Commands that only list aliases report an +empty set; resolving a specific alias fails with the AL5 unknown-alias error. + +#### AL3: Alias names + +An alias name must match `[a-zA-Z0-9][a-zA-Z0-9._-]*` — it may not contain +`:`, `/`, `=`, or whitespace, so it can never be confused with a path, a git +URL, or a `key=value` argument. Names are validated when an alias is created; +an alias file containing an invalid name is rejected at load time. + +--- + +## Reference Syntax + +### Behaviors + +#### AL4: A leading colon marks an alias reference + +An argument of the form `:` is an alias reference. Any other argument is +a module source and follows the existing local-path / git-URL rules unchanged. +A bare `:` with no name is an error. + +#### AL5: Unknown aliases do not fall through + +An alias reference that is not defined in the alias file is an error naming the +alias and the file it was looked up in. It is never retried as a git URL, so a +mistyped alias reports a mistyped alias rather than a failed clone. + +#### AL6: Top-level alias dispatch defaults to `run` + +`loom :bar [args...]` is exactly equivalent to `loom run :bar [args...]`. The +rewrite happens before command dispatch and only when the first argument begins +with `:`, so every other argument keeps normal subcommand dispatch — an unknown +subcommand still reports `unknown command "..." for "loom"` with its usual +suggestions. + +#### AL7: Alias references are accepted by the commands that run a module + +`loom run` and `loom diff` accept `:` in place of a module source. + +`loom validate` and `loom bulk` do not. Neither consumes an alias's params — +`validate` resolves no remote sources at all, and `bulk` embeds the source it +is given into the module it generates, where an alias reference would violate +AL8. Extending them is additive and deliberately out of scope here. + +#### AL8: Aliases are never resolved inside a module + +`spec.modules[].source` is resolved by the module executor, which does not +consult the alias file. A module referencing `:bar` fails to resolve it as a +source, keeping modules portable between operators. + +--- + +## Parameter Merging + +### Behaviors + +#### AL9: Alias params are the lowest-precedence source + +Parameters are merged in this order, each overriding the previous: + +| Precedence | Source | +|-----------|--------| +| 1 (lowest) | Alias `params` | +| 2 | `--params-file` | +| 3 (highest) | `-p key=value` | + +Module-level `spec.params` defaults still apply beneath all three: an alias +param supplies a value, so it suppresses the module's default exactly as a `-p` +would. + +--- + +## Alias Management + +The `loom alias` command group manages the alias file. All subcommands operate +on the AL1 path and create the parent directory as needed. + +### Behaviors + +#### AL10: `alias add` creates an entry + +`loom alias add [-p key=value ...]` writes an entry. The name +may be given with or without a leading `:`. Adding a name that already exists +is an error unless `--force` is given, so an existing alias is never silently +replaced. + +#### AL11: `alias remove` deletes an entry + +`loom alias remove ` deletes the entry. Removing an alias that does not +exist is an error. + +#### AL12: `alias list` reports every alias + +`loom alias list` prints each alias name with its source and params, sorted by +name. With no aliases defined it reports that none are, and exits 0. + +#### AL13: Writes preserve the rest of the file + +Adding or removing an alias rewrites the file from the parsed alias set. A +write never partially updates the file: it is written to a temporary file in +the same directory and renamed into place. + +### Error Conditions + +| Condition | Error | +|-----------|-------| +| Bare `:` reference | `empty alias name: expected :` | +| Alias not defined | `unknown alias "" (looked in )` | +| Invalid name at add time | `invalid alias name "": must match [a-zA-Z0-9][a-zA-Z0-9._-]*` | +| Invalid name in file | `invalid alias name "" in : must match [a-zA-Z0-9][a-zA-Z0-9._-]*` | +| Entry missing a source | `alias "" in has no source` | +| `add` over an existing name | `alias "" already exists (use --force to replace)` | +| `remove` of an unknown name | `unknown alias "" (looked in )` |