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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Install an alias for the docket binary: design

Change 0534 — Install an alias for the docket binary

Change #534, groomed interactively on 2026-10-05, split out of #532 in the private-visibility series (#529–#533 plus this change and the private-instructions change). It is independent and can land first.

## Summary

Every install also places `dckt`, a symlink to the `docket` binary, in the same bin directory. The private-instructions change in this series needs it: its user-level Claude hook and Codex/OpenCode pointer must not contain the word "docket", so they invoke `dckt instructions`. Nothing else switches to the alias. Skills keep resolving `docket` from the capability catalog.

## Evidence gathered at grooming

- **Two install paths place the binary.**
- **Public release install** (v1.0.0-alpha.1 already uses it): `internal/release/downloader/install.sh` is rendered into every release bundle by `internal/release/render.go` and published as a release asset. It:
- downloads the archive and verifies its SHA-256 before extraction;
- stages the binary in the bin directory (`.docket-stage.XXXXXX`) and runs `"$stage" install …`;
- `mv -f`s it to `$bin_dir/docket`;
- writes its own ownership record at `${XDG_STATE_HOME:-~/.local/state}/docket/release-binary.record`.

Its contract comment pins the runtime dependencies (`/bin/sh`, `curl`, `tar`, one SHA-256 tool), the refusal to replace a binary it does not own, and "no --force path". `tests/test_release_downloader.sh`, `tests/test_release_downloader_refusals.sh`, `tests/test_release_downloader_converge.sh`, and `scripts/release-smoke.sh` depend on its spellings and PATH sandbox.
- **Development install:** the repository-root `install.sh` is a thin bootstrapper that delegates to `docket development install` (`internal/install/devmode.go`, `binaryName = "docket"`, `resolveBinDir` defaulting to `UserRoots.BinDir` = `${XDG_BIN_HOME:-~/.local/bin}`). That command builds the binary and places it inside a journaled install transaction.
- **Uninstall never removes the binary.** `internal/install/uninstall.go`: "The installation's own binary and every unattributed target remain recorded and untouched."
- **Skills build every invocation from the capability catalog,** whose argv spells `docket`. The binary's commands do not depend on the name it was invoked by. The plan verifies this against the CLI root.

## Decisions (settled with the human)

1. The alias is `dckt`, a symlink to the installed `docket`, in the same bin directory.
2. Both install paths create it. The release downloader is the path users take from v1.0, and the development installer is the path behind the repository-root `install.sh`.
3. The alias follows the binary: same placement, same ownership posture, left in place by `uninstall`.
4. A foreign `dckt` is never overwritten, and there is no force path.
5. Sparse documentation: installer output and the downloader's usage text only, no `docs/` pages.

## Design

### Release downloader

After the verified binary is moved into `$bin_dir/docket`, the downloader ensures `$bin_dir/dckt`:

- **Absent:** create a symlink to `docket`, using a relative target so moving the bin directory keeps it valid.
- **Already a symlink resolving to `$bin_dir/docket`:** leave it (converge).
- **Anything else:** leave it untouched, print a one-line warning naming the path and the remedy, and finish the install successfully. The binary install is never failed by the alias.

The ownership record gains the alias path, so a later run can tell its own alias from a foreign one. The contract comment, usage text, and tests are updated: `ln -s` is admitted by the spelling ban and the PATH sandbox, and the tests cover create, converge, and foreign refusal. `scripts/release-smoke.sh` asserts that the alias exists after a smoke install.

### Development installer

`docket development install` applies the same three-way rule beside the binary it places, inside its journaled transaction. A rollback of a failed install removes an alias it created in that transaction and leaves any pre-existing one alone. The repository-root `install.sh` changes only its header comment, which lists what an install produces.

### Install check and uninstall

- `docket install check` reports a missing or foreign `dckt` as a finding with its remedy. It is a finding, not a refusal.
- `docket uninstall` leaves the alias in place, exactly as it leaves the binary.

## Acceptance criteria

1. A fresh release install (downloader against a local release fixture) leaves `$bin_dir/dckt` resolving to `$bin_dir/docket`. A second run converges with no change.
2. A fresh development install does the same.
3. A pre-existing foreign `dckt` is untouched by both installers, both installs succeed with a warning, and `install check` reports it.
4. `dckt version --json` and `docket version --json` report identical output.
5. `uninstall` leaves both binary and alias in place.
6. The downloader's spelling-ban and PATH-sandbox tests pass with the added `ln -s`. Removing the alias step turns the new downloader test red (mutation-tested).

## Out of scope

- Renaming the binary or the catalog spelling.
- Shell aliases, completions, and package formulas.
- The user-level surfaces that invoke `dckt` (the private-instructions change).
2 changes: 2 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
#
# The heavy lifting (building the binary, linking harnesses, adopting legacy artifacts,
# the journaled install transaction) lives in the Go engine reached through that command.
# An install leaves the docket binary plus dckt, a symlink to it, in the bin directory; a dckt
# docket did not create is never replaced (the install warns and leaves it alone).
#
# DOCKET_BOOTSTRAP_DRY_RUN=1 prints the resolved command to stdout and exits 0 without
# running it — the test seam this file's own tests key on.
Expand Down
9 changes: 9 additions & 0 deletions internal/app/finding_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ const (
// collection surfaces when a version tree could not be reclaimed; the primary
// install/uninstall result is unaffected.
FCCollectionPending FindingCode = "collection-pending"

// binary-alias-missing / binary-alias-foreign are the warning codes an
// install check or development install surfaces for the dckt alias beside
// an installed binary. They are warnings only: the primary result is
// unaffected.
FCBinaryAliasForeign FindingCode = "binary-alias-foreign"
FCBinaryAliasMissing FindingCode = "binary-alias-missing"
)

// AllFindingCodes is the authoritative, sorted, deduplicated vocabulary of every
Expand Down Expand Up @@ -189,6 +196,8 @@ var AllFindingCodes = []FindingCode{
FindingCode(ReasonAttachPathOccupied),
FCArtifactRenderFailed,
FCAuthoredInputTooLarge,
FCBinaryAliasForeign,
FCBinaryAliasMissing,
FCBranchMalformed,
FindingCode("branch-still-exists"),
FCCollectionPending,
Expand Down
18 changes: 18 additions & 0 deletions internal/app/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,27 @@ func NewInstallResult(operation string, out install.Outcome) InstallResult {
if collectionNeedsAttention(out.Collection) {
r.Warnings = append(r.Warnings, collectionWarning(out.Collection))
}
r.Warnings = append(r.Warnings, aliasWarnings(out.AliasFindings)...)
return r
}

// aliasWarnings renders the dckt alias findings as warnings. The alias is a
// convenience beside the binary, so its absence or a foreign occupant is
// reported with its remedy and never reclassifies the operation.
func aliasWarnings(findings []install.AliasFinding) []InstallWarning {
var out []InstallWarning
for _, f := range findings {
code, msg := FCBinaryAliasMissing, "the dckt alias for "+f.Binary+" is missing"
if f.Kind == install.AliasForeign {
code, msg = FCBinaryAliasForeign, "dckt here is not docket's alias for "+f.Binary+"; left untouched"
}
out = append(out, InstallWarning{Diagnostic: config.Diagnostic{
Code: string(code), Severity: config.SeverityWarning, Path: f.Path, Message: msg, Remedy: f.Remedy,
}})
}
return out
}

// classifyInstall maps the service's stable reason to the protocol result.
//
// The default is deliberately internal-error rather than a plausible-looking
Expand Down
34 changes: 34 additions & 0 deletions internal/app/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,37 @@ func TestInstallResultNoWarningsOmitsField(t *testing.T) {
t.Fatalf("human text has a phantom warning line:\n%s", r.HumanText())
}
}

// A dckt finding is a warning: it never changes the result class, so a check
// that only finds an alias problem still exits clean.
func TestInstallResultRendersAliasFindingsAsWarnings(t *testing.T) {
out := install.Outcome{
Mode: install.ModeRelease,
AliasFindings: []install.AliasFinding{
{Kind: install.AliasMissing, Path: "/b/dckt", Binary: "/b/docket", Remedy: "re-run"},
{Kind: install.AliasForeign, Path: "/c/dckt", Binary: "/c/docket", Remedy: "move it"},
},
}
r := NewInstallResult(OperationInstallCheck, out)
if r.Result != ResultNoOp {
t.Fatalf("result = %q, want %q: an alias finding must not change the class", r.Result, ResultNoOp)
}
if len(r.Warnings) != 2 {
t.Fatalf("warnings = %+v", r.Warnings)
}
want := []struct{ code, path, remedy string }{
{string(FCBinaryAliasMissing), "/b/dckt", "re-run"},
{string(FCBinaryAliasForeign), "/c/dckt", "move it"},
}
for i, w := range want {
got := r.Warnings[i]
if got.Code != w.code || got.Path != w.path || got.Remedy != w.remedy ||
got.Severity != config.SeverityWarning || got.Message == "" {
t.Errorf("warning[%d] = %+v, want code %s path %s remedy %s", i, got, w.code, w.path, w.remedy)
}
}
human := r.HumanText()
if !strings.Contains(human, "warning: /c/dckt") || !strings.Contains(human, "move it") {
t.Errorf("human text does not show the foreign alias warning with its remedy:\n%s", human)
}
}
34 changes: 34 additions & 0 deletions internal/bashupgrade/alias_invocation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package bashupgrade

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

"github.com/danielhanold/docket/internal/install"
"github.com/danielhanold/docket/internal/testsupport"
)

// The alias is only useful if the binary behaves the same whatever name it was
// invoked by. This uses the real built binary (shared with the upgrade tests via
// buildDocket's sync.Once, so it costs no extra build) behind a dckt symlink.
func TestAliasInvocationMatchesDocket(t *testing.T) {
bin := buildDocket(t)
dir := testsupport.TempDir(t)
alias := filepath.Join(dir, install.AliasName)
if err := os.Symlink(bin, alias); err != nil {
t.Fatal(err)
}
run := func(path string) string {
t.Helper()
out, err := exec.Command(path, "version", "--json").Output()
if err != nil {
t.Fatalf("%s version --json: %v", path, err)
}
return string(out)
}
if got, want := run(alias), run(bin); got != want {
t.Fatalf("dckt version --json = %q, docket version --json = %q", got, want)
}
}
18 changes: 13 additions & 5 deletions internal/bashupgrade/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,13 +426,17 @@ func mirrorInstallBinary(t *testing.T, c *upgradeCase, st *runState, body string

// installHandoff mirrors internal/release/downloader/install.sh from the point it
// has a verified binary: stage it beside the destination, run the staged binary's
// `install --harness claude`, and only on success move it into place, write the
// ownership record, and run `docket install check`.
// `install --harness claude`, and only on success move it into place, place the
// relative dckt alias beside it, write the ownership record (naming the alias), and
// run `docket install check`.
func installHandoff(t *testing.T, c *upgradeCase, st *runState) {
t.Helper()
dest := filepath.Join(c.BinDir, "docket")
if _, err := os.Lstat(dest); err == nil {
t.Fatalf("%s already exists; the mirror covers only the downloader's fresh-install path", dest)
alias := filepath.Join(c.BinDir, "dckt")
for _, p := range []string{dest, alias} {
if _, err := os.Lstat(p); err == nil {
t.Fatalf("%s already exists; the mirror covers only the downloader's fresh-install path", p)
}
}
if err := os.MkdirAll(c.BinDir, 0o755); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -506,12 +510,16 @@ func installHandoff(t *testing.T, c *upgradeCase, st *runState) {
if err := os.Rename(stage, dest); err != nil {
t.Fatal(err)
}
// The downloader's fresh-install alias step: `ln -s docket "$bin_dir/dckt"`.
if err := os.Symlink("docket", alias); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(bin)
recDir := filepath.Join(c.Home, ".local", "state", "docket")
if err := os.MkdirAll(recDir, 0o755); err != nil {
t.Fatal(err)
}
rec := "path=" + dest + "\nversion=development\nsha256=" + hex.EncodeToString(sum[:]) + "\n"
rec := "path=" + dest + "\nversion=development\nalias=" + alias + "\nsha256=" + hex.EncodeToString(sum[:]) + "\n"
if err := os.WriteFile(filepath.Join(recDir, "release-binary.record"), []byte(rec), 0o644); err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading