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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,55 @@

## [Unreleased]

## [0.3.0-preview.2] — 2026-09-21

**A preview**, on the same terms as the one before it: a GitHub pre-release, so
`godo -e update` does not offer it and neither Homebrew nor Scoop carries it.

All of it was found by running `v0.3.0-preview.1` on a real Windows host, which
is what the preview was for. The roadmap said Windows shell detection was
written from documentation and unverified; it was also wrong, and so were two
other things.

Still unverified, and the reason this is preview.2 rather than 0.3.0: the
junction syscall runs only on Windows. Its reparse buffer is built in portable
code and tested field by field, but `DeviceIoControl` itself has been executed
by nobody.

### Fixed
- **Every PowerShell user on Windows was told they were in `cmd`.** Detection
read the parent process, and something is usually in between: scoop installs
godo as `shims\godo.exe`, which starts the real `godo.exe` as a child, so the
parent of the process asking the question was *godo*. Not a shell, so the
answer fell through to `%ComSpec%`. It now walks up the process tree to the
nearest shell, which also covers npm, bun and make wrappers and an editor's
terminal.
- **A catalog written on Windows lost its decorators.** With CRLF the YAML
parser files the comment above a key as the *previous* key's foot comment, so
`# @dialect matcher` decorated nothing — unless a blank line happened to sit
above it, which is why it read as "no space between the command and the
comment breaks it". Line endings are normalized before parsing, which YAML
already calls the same line break. It also keeps a `\r` out of a block
scalar, where it was being handed to the shell as part of the command.
- **`fs.slink` did nothing on Windows.** It called `os.Symlink`, and a symlink
there is a privilege rather than a file operation, so an ordinary user got
"A required privilege is not held by the client". It now uses the link
Windows actually offers — a **junction** for a directory, a **hard link** for
a file — neither of which needs a privilege. The op is the same op; the
mechanism is the platform's.

### Added
- A decorator YAML puts somewhere godo does not read is now an error naming
where it belongs, instead of being dropped: `ins: x # @deps y` files the
comment on the value, and a decorator after the last key files as that key's
foot comment. Prose that merely mentions `@deps` is still a comment.

### Changed
- `godo -e runners` says **how** the default shell was chosen — `your $SHELL`,
`found in the process tree`, `set by GODO_SHELL`, `%ComSpec%, because no
shell is in the process tree`. A wrong answer is only diagnosable if you can
tell a stale `GODO_SHELL` from a fallback, and a path alone cannot.

## [0.3.0-preview.1] — 2026-09-20

**A preview.** It is a GitHub pre-release, so `godo -e update` does not offer
Expand Down
23 changes: 19 additions & 4 deletions docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,16 @@ Selection, in order:
|--|--|
| `GODO_SHELL` | Always wins |
| Unix | `$SHELL`, else `/bin/sh` |
| Windows | the parent process when it is a shell, else `%ComSpec%` |
| Windows | the nearest shell in the process tree, else `%ComSpec%` |

godo answers "which shell am I in" from the parent process, which is the only
thing that knows. No command run *inside* a shell can report it — it would only
godo answers "which shell am I in" by walking up the process tree, which is
the only thing that knows. No command run *inside* a shell can report it — it would only
describe the shell godo just started. To confirm it yourself, in your own
terminal: `echo $0` (sh, bash, zsh, dash, ksh), `echo $version` (fish),
`$PSVersionTable.PSVersion` (PowerShell), `echo %COMSPEC%` (cmd). `godo -e
runners` prints these too.

Windows reads the parent process because the environment cannot answer:
Windows reads the process tree because the environment cannot answer:
PowerShell sets `PSModulePath` and everything it starts inherits it, so a
`cmd.exe` opened from PowerShell would look like PowerShell. On Unix, `$SHELL`
is the login shell rather than the one running right now — bash started inside
Expand Down Expand Up @@ -330,6 +330,21 @@ that runner's bodies are not expanded either way.

YAML comment block **immediately above** the script key. Apply to scripts only.

Above the key is the only placement that works, and godo refuses the two that
look like they should:

```yaml
ins: bun install # @dialect matcher <- error
# @deps ins <- error, if nothing follows it
```

YAML files a trailing comment on the value and a comment after the last key on
that key, neither of which decorates anything. Silently dropping them meant a
catalog ran with a dialect or a dependency list its author believed they had
written, and the symptom arrived far away — a script matching nothing, a
dependency that never ran. A comment that merely mentions `@deps` in prose is
still a comment; only one that *starts* with a decorator is refused.

| Line | |
|------|--|
| `# text` (no `@`) | Doc |
Expand Down
7 changes: 7 additions & 0 deletions docs/dev/plugin-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ unbuffered, so a plugin that spoke first would deadlock.
{"code":0,"ok":true,"stdout":"","stderr":"","error":""}
```

`slink` makes the link the platform offers: a **symlink** on Unix, and on
Windows a **junction** for a directory or a **hard link** for a file. Windows
treats a symlink as a privilege rather than a file operation, so asking for one
fails for an ordinary user; a junction and a hard link do not. Consequence: on
Windows the target must exist, because which link to make depends on what it
is, and a hard link cannot cross volumes.

`error` is godo refusing — an unknown op, or a capability the catalog did not
grant. A command that ran and failed is `code`, not `error`.

Expand Down
13 changes: 10 additions & 3 deletions docs/guide/runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ A shell that is not installed fails when the plan is built, so nothing runs.

```
$ godo -e runners
inherit /bin/zsh [default]
inherit /bin/zsh
the default runner, your $SHELL

shells found here:
sh /bin/sh
Expand All @@ -103,12 +104,18 @@ Selection order:
|--|--|
| `GODO_SHELL` | Always wins |
| Unix | `$SHELL`, else `/bin/sh` |
| Windows | the parent process when it is a shell, else `%ComSpec%` |
| Windows | the nearest shell in the process tree, else `%ComSpec%` |

Windows reads the parent process because the environment cannot answer:
Windows reads the process tree because the environment cannot answer:
PowerShell sets `PSModulePath` and everything it starts inherits it, so a
`cmd.exe` opened from PowerShell would look like PowerShell.

It walks the tree rather than reading the parent alone because something is
usually in between. A package manager's shim is the common one: scoop installs
godo as `shims\godo.exe`, which starts the real `godo.exe` as a child — so the
parent of the process asking the question is *godo*. The same shape appears
with npm, bun and make wrappers, and inside an editor's terminal.

On Unix, `$SHELL` is your *login* shell, not necessarily the one running right
now — bash started inside zsh still reports zsh. Every other tool follows that
convention; `GODO_SHELL` is how you disagree with it.
Expand Down
4 changes: 2 additions & 2 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ A preview is published as a GitHub **pre-release**. `godo -e update` reads the
arrives on its own — you go and get it:

```bash
go install github.com/my-rv/godo/cmd/godo@v0.3.0-preview.1
go install github.com/my-rv/godo/cmd/godo@v0.3.0-preview.2
```

or download the `v0.3.0-preview.1` assets from
or download the `v0.3.0-preview.2` assets from
[Releases](https://github.com/MY-RV/godo/releases).

On a preview binary `godo -e update check` reports no update available: the
Expand Down
8 changes: 5 additions & 3 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This is what we **commit to communicate**. Pre-1.0 APIs can still change within

**Not promised in v0.1:** Homebrew/Scoop installs, dialects `nscript`/`matchns`, stable Go API.

## v0.3 — previewed as `v0.3.0-preview.1`
## v0.3 — previewed as `v0.3.0-preview.2`

Breaking: the shell that runs your scripts changes, and file fields move into
`engine:`. It was held until plugin loading worked — `engine:` without a loader
Expand All @@ -25,7 +25,7 @@ is half a promise, and this is the release where the promise gets made.
A preview is a GitHub pre-release: `godo -e update` does not offer it, and
neither Homebrew nor Scoop carries it. It is on
[Releases](https://github.com/MY-RV/godo/releases) and on
`go install github.com/my-rv/godo/cmd/godo@v0.3.0-preview.1`.
`go install github.com/my-rv/godo/cmd/godo@v0.3.0-preview.2`.

| Promise | |
|---------|--|
Expand All @@ -46,7 +46,9 @@ Everything in the table above.
### Before v0.3.0 ships

- Preview feedback. Plugin loading is new, and the preview is where it gets
found out.
found out. preview.1 on Windows already produced three fixes.
- **Windows verification.** preview.2 fixes shell detection, CRLF catalogs and
`fs.slink`; the junction syscall behind `slink` has still been run by nobody.
- The plugin protocol is **not** frozen by this preview. A plugin is pinned by
digest, so a protocol change cannot silently break a catalog — it fails by
naming the plugin.
Expand Down
7 changes: 3 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ module github.com/my-rv/godo

go 1.26.0

require gopkg.in/yaml.v3 v3.0.1

require (
github.com/tetratelabs/wazero v1.12.0 // indirect
golang.org/x/sys v0.44.0 // indirect
github.com/tetratelabs/wazero v1.12.0
golang.org/x/sys v0.44.0
gopkg.in/yaml.v3 v3.0.1
)
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
Expand Down
102 changes: 102 additions & 0 deletions internal/catalog/decorator_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package catalog_test

import (
"strings"
"testing"

"github.com/my-rv/godo/internal/catalog"
)

// A decorator at the end of the line is the failure a Windows user hit on the
// 0.3.0 preview: YAML files it as a line comment, godo read nobody's comment,
// and the catalog ran with a dialect its author believed they had written.
func TestDecorator_trailingOnTheLineIsAnError(t *testing.T) {
dir := t.TempDir()
path := writeCat(t, dir, "version: \"0.1\"\nscripts:\n ins: bun install # @dialect matcher\n b: echo b\n")

_, err := catalog.LoadFile(path)
if err == nil {
t.Fatal("want an error, got a catalog that quietly ignored the decorator")
}
for _, want := range []string{"@dialect", "not read", "own line above the key"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error %q does not say %q", err, want)
}
}
}

// After the last key there is no next key to decorate, so YAML files it as
// that key's foot comment. Silently dropped before.
func TestDecorator_belowTheLastKeyIsAnError(t *testing.T) {
dir := t.TempDir()
path := writeCat(t, dir, "version: \"0.1\"\nscripts:\n a: echo a\n # @deps a\n")

_, err := catalog.LoadFile(path)
if err == nil || !strings.Contains(err.Error(), "@deps") {
t.Fatalf("err=%v", err)
}
}

// The check must not turn ordinary comments into errors.
func TestDecorator_proseMentioningOneIsStillAComment(t *testing.T) {
dir := t.TempDir()
path := writeCat(t, dir, "version: \"0.1\"\nscripts:\n a: echo a # like @deps but not\n b: echo b\n")

if _, err := catalog.LoadFile(path); err != nil {
t.Fatalf("a comment that merely names a decorator is a comment: %v", err)
}
}

// The placement that works has to keep working, blank line or not.
func TestDecorator_aboveTheKeyIsRead(t *testing.T) {
for name, doc := range map[string]string{
"no blank line": "version: \"0.1\"\nscripts:\n a: echo a\n # @dialect matcher\n ${x}: echo ${godo:argv[x]}\n",
"blank line": "version: \"0.1\"\nscripts:\n a: echo a\n\n # @dialect matcher\n ${x}: echo ${godo:argv[x]}\n",
} {
t.Run(name, func(t *testing.T) {
dir := t.TempDir()
cat, err := catalog.LoadFile(writeCat(t, dir, doc))
if err != nil {
t.Fatal(err)
}
if cat.Scripts[1].Dialect != "matcher" {
t.Fatalf("dialect=%q, want matcher", cat.Scripts[1].Dialect)
}
})
}
}

// A catalog written on Windows has CRLF, and the YAML parser files comments
// differently for it: the decorator directly above its key became the previous
// key's foot comment and decorated nothing. A blank line above the comment
// happened to hide it, which is why the report was "no space between the
// command and the comment breaks it".
func TestDecorator_crlfCatalogReadsTheDecorator(t *testing.T) {
doc := "version: \"0.1\"\nscripts:\n a: echo a\n # @dialect matcher\n ${x}: echo ${godo:argv[x]}\n"
dir := t.TempDir()
path := writeCat(t, dir, strings.ReplaceAll(doc, "\n", "\r\n"))

cat, err := catalog.LoadFile(path)
if err != nil {
t.Fatalf("a CRLF catalog is a catalog: %v", err)
}
if cat.Scripts[1].Dialect != "matcher" {
t.Fatalf("dialect=%q, want matcher", cat.Scripts[1].Dialect)
}
}

// The same normalization keeps \r out of a block scalar, where it would reach
// the shell as part of the command.
func TestDecorator_crlfDoesNotLeakIntoABody(t *testing.T) {
doc := "version: \"0.1\"\nscripts:\n a: |\n echo one\n echo two\n"
dir := t.TempDir()
path := writeCat(t, dir, strings.ReplaceAll(doc, "\n", "\r\n"))

cat, err := catalog.LoadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(cat.Scripts[0].Commands[0], "\r") {
t.Fatalf("body carries a carriage return: %q", cat.Scripts[0].Commands[0])
}
}
Loading
Loading