From 4bd9644a3b1bf023b050122404ba85797dbc3005 Mon Sep 17 00:00:00 2001
From: Himanshu Singh
Date: Mon, 7 Sep 2026 00:20:29 +0530
Subject: [PATCH] tula v0.1.1
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The install line pasted a second time — which is how most people fix a PATH
problem — downloaded and re-verified the whole 21 MB archive to arrive at the
file it already had. It recognises the version under `~/.tula/versions` now and
goes straight to relinking: 2.7s to 0.3s on a warm link, and from minutes to
under a second on a cold one. The short cut needs the launcher symlink to be
there already, not merely the directory. A directory is something anyone able to
write under the install tree can put there before tula is ever installed, and a
first install used to download over whatever it found — where adopting it would
link an unread binary and say "already installed". `TULA_FORCE=1` fetches and
checks again regardless, and `install-test.sh` plants a version directory to
prove the fast path declines it.
`-s` silenced curl's meter on every fetch, including the one that takes minutes,
so a single `downloading` line stood unchanged for the whole transfer: 152
seconds of silence in one capture, between the first line and the next. The
archive keeps the meter; the 386-byte checksum file does not. The download is
GitHub's and is not faster — it is visibly moving. A transfer under 1 KB/s for
30 seconds is ended rather than waited on, which is the difference between a
slow link and a dead one.
The banner carries a `v` before the version, as the tag and every `git` and
`npm` line about the build already do, and the directory the shell was opened
in. tula reads nothing from that directory; it is what tells two terminals
apart. It is stripped of control characters and capped like any other text tula
did not write — every byte but `/` and NUL is legal in a directory name, so a
cloned repository can carry one named to repaint whatever draws it, and Ink
writes text verbatim. Paths in the installer's report are written from home for
the same reason the banner's is: the absolute form is mostly the reader's own
username, and the part they would redact before pasting a screenshot.
`release.yml` skips npm packages already published rather than dying on the
first one under `set -e`, which is what made v0.1.0's recovery uncertain after
a token that could not bypass 2FA failed the job part-way through. AGENTS.md
says which token types actually bypass it, since the claim that a token always
does was disproved, and the pre-release table gains the `release` environment —
naming it in the workflow does nothing until it exists.
Co-Authored-By: Claude Opus 5 (1M context)
---
.github/workflows/release.yml | 19 ++++++-
AGENTS.md | 16 ++++--
CHANGELOG.md | 12 ++++-
README.md | 2 +-
SECURITY.md | 2 +-
install.sh | 97 ++++++++++++++++++++++++++---------
package.json | 2 +-
scripts/install-test.sh | 64 ++++++++++++++++++++++-
site/app/install/page.tsx | 7 ++-
site/components/Session.tsx | 6 ++-
site/lib/site.ts | 2 +-
src/core/paths.test.ts | 43 ++++++++++++++++
src/core/paths.ts | 22 ++++++++
src/ui/app.tsx | 9 +++-
src/ui/screen.test.ts | 10 +++-
src/version.ts | 2 +-
16 files changed, 273 insertions(+), 42 deletions(-)
create mode 100644 src/core/paths.test.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 390e576..b3510ab 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -291,15 +291,30 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
PRERELEASE: ${{ needs.release.outputs.prerelease }}
+ VERSION: ${{ needs.release.outputs.version }}
run: |
set -euo pipefail
tag=$([ "$PRERELEASE" = true ] && echo next || echo latest)
+ # Already-published versions are skipped rather than retried. npm
+ # refuses to publish over one, and under `set -e` that aborts the whole
+ # job — so a run that failed part-way through, as v0.1.0's did on a
+ # token that could not bypass 2FA, could not simply be re-run. It died
+ # on the first package that had landed, leaving the rest unpublished
+ # and the install page naming a channel that 404s.
+ publish() {
+ name=$(node -p "require('./$1/package.json').name")
+ if npm view "$name@$VERSION" version >/dev/null 2>&1; then
+ echo "::notice::$name@$VERSION is already published; skipping"
+ return 0
+ fi
+ npm publish "$1" --access public --provenance --tag "$2"
+ }
# Platform packages first: the wrapper's optionalDependencies name
# them, and a wrapper published ahead of them installs a placeholder.
for pkg in dist/npm/tula-*; do
- npm publish "$pkg" --access public --provenance --tag "$tag"
+ publish "$pkg" "$tag"
done
- npm publish dist/npm/tula --access public --provenance --tag "$tag"
+ publish dist/npm/tula "$tag"
homebrew:
needs: release
diff --git a/AGENTS.md b/AGENTS.md
index 0fd90b3..a77ded9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -806,6 +806,7 @@ release is already public, while the site is telling people to use them.
| The Vercel root directory is `site` | `vercel.json`'s headers are read from there and nowhere else | `curl -sI https://usetu.la/` |
| Vercel includes files outside that root | the build script copies `install.sh` in from there; without it the build fails | `curl -sI https://usetu.la/install.sh` |
| `APPLE_*` secrets | optional; without them macOS ships unsigned | `gh secret list` |
+| A `release` environment with a required reviewer | `release.yml` names it on all three jobs, and naming it does nothing until it exists — GitHub silently creates an unprotected one on first use, and the run publishes unreviewed | `curl -s -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/hsnice16/tula/environments/release` |
Set each variable last, after its token exists: `true` without the token turns a
skipped job into a failed one, and it fails after the GitHub release is public.
@@ -833,10 +834,17 @@ and the second resolves the name as an account, so it succeeds for anybody's.
The scope list on npm's token page is the answer — it offers only what you can
publish to.
-npm publishes from a token, so account 2FA never gates CI. `auth-only` is
-therefore free — `npm profile enable-2fa auth-only` — and worth having: without
-it a password is enough to publish `@hsnice16/tula`, which is the one entrance this
-project's supply-chain argument would not cover.
+The token has to be one that bypasses 2FA, and not every kind does. A classic
+**Publish** token does not: with 2FA on writes it demands an OTP, and the job
+fails `EOTP` after the GitHub release is already public — which is how v0.1.0
+went out with npm empty for half an hour. Use a **granular access token** scoped
+to the `@hsnice16` *scope* with read and write, or a classic **Automation**
+token. Scope, not package: the per-package selector cannot name packages that do
+not exist yet, which is every one of them on a first release.
+
+Do not reach for `npm profile enable-2fa auth-only` instead. It makes a password
+enough to publish `@hsnice16/tula` — the one entrance this project's supply-chain
+argument would not cover — and buys nothing the token type does not already give.
`gh` is also what verifies an attestation, so a maintainer who cannot run
`gh attestation verify` cannot check the first release the way the install page
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d45272f..332030f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,15 @@ CI and build plumbing, refactors, and doc-only edits — stays in commit message
## [Unreleased]
+## [0.1.1] - 2026-09-06
+
+### Changed
+
+- **A version you already have is not downloaded again.** The installer resolved the version and then downloaded and re-verified the whole 21 MB archive regardless of whether that exact build was already on disk — so the install line pasted a second time, which is how most people fix a PATH problem, paid the full download to arrive at the file it already had. It now recognises the version under `~/.tula/versions` and goes straight to relinking and the PATH line — 2.7s to 0.3s on a warm link, and from minutes to under a second on a cold one. `TULA_FORCE=1` fetches and checks again for anyone who wants that. The short cut needs the launcher symlink to be there already, not just a directory: anyone able to write under the install tree could otherwise plant a version directory before tula was ever installed and have the first install adopt it unread, where that install used to download over it.
+- **The download says how far along it is.** `-s` silenced curl's meter for every fetch, including the one that takes minutes, so a single `downloading` line sat unchanged for the whole transfer and read as a hang — 152 seconds of silence, in one capture, between the first line and the next. The download is GitHub's and is not faster; it is now visibly moving. The archive keeps the meter now; the 386-byte checksum file does not. A transfer under 1 KB/s for 30 seconds is ended rather than waited on, which is the difference between a slow link and a dead one.
+- **Paths in the installer's report are written from home.** `~/.tula/bin/tula` rather than the absolute form, which is mostly the reader's own username and the part they would redact before pasting a screenshot into an issue. The `export PATH=` line stays absolute, because a `~` inside those quotes is not a home directory to any shell.
+- **The banner carries a `v` before the version, and the directory you are in.** The `v` is how the tag, the release and every `git` and `npm` line about the build already spell it; the bare number was the odd one out. The directory is written from home too, which keeps the reader's own username off a screenshot. tula reads nothing from it — the book is the same wherever the shell was opened — but it is what tells two terminals apart at a glance. It is stripped of control characters and capped like any other text tula did not write: every byte but `/` and NUL is legal in a directory name, so a cloned repository can carry one named to repaint whatever draws it.
+
## [0.1.0] - 2026-09-06
The first release: real venues on a published install path, the query shell, and
@@ -161,5 +170,6 @@ what breaks first.
- `KeyScope` is tri-state. Kraken exposes no endpoint reporting a key's permissions, and every endpoint gated on trade permission mutates an order, so `canTrade` is `unknown` rather than guessed at. Withdraw scope is provable, and is proven.
- Kraken margin and open orders are not read yet, so on a margin account this is not a complete Kraken picture.
-[Unreleased]: https://github.com/hsnice16/tula/compare/v0.1.0...HEAD
+[Unreleased]: https://github.com/hsnice16/tula/compare/v0.1.1...HEAD
+[0.1.1]: https://github.com/hsnice16/tula/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/hsnice16/tula/releases/tag/v0.1.0
diff --git a/README.md b/README.md
index 723846a..3080d28 100644
--- a/README.md
+++ b/README.md
@@ -132,7 +132,7 @@ the sigstore-backed attestation proving this repository's release workflow built
it wherever the GitHub CLI can — saying so either way. Check one by hand:
```bash
-gh attestation verify tula-v0.1.0-darwin-arm64.tar.gz --repo hsnice16/tula \
+gh attestation verify tula-v0.1.1-darwin-arm64.tar.gz --repo hsnice16/tula \
--signer-workflow hsnice16/tula/.github/workflows/release.yml
```
diff --git a/SECURITY.md b/SECURITY.md
index 8e1f032..d5e09e1 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -133,7 +133,7 @@ keyless, so there is no signing key for this project to generate, publish, rotat
or lose.
```bash
-gh attestation verify tula-v0.1.0-darwin-arm64.tar.gz --repo hsnice16/tula \
+gh attestation verify tula-v0.1.1-darwin-arm64.tar.gz --repo hsnice16/tula \
--signer-workflow hsnice16/tula/.github/workflows/release.yml
```
diff --git a/install.sh b/install.sh
index 9031071..66ac300 100755
--- a/install.sh
+++ b/install.sh
@@ -14,6 +14,8 @@
# TULA_INSTALL_DIR root of the install tree (default ~/.tula)
# TULA_REQUIRE_ATTESTATION refuse to install at all unless provenance is proven
# TULA_NO_MODIFY_PATH do not touch any shell profile
+# TULA_FORCE download and check again even if this version is
+# already installed
set -eu
REPO="hsnice16/tula"
@@ -25,6 +27,15 @@ BROWSE="https://github.com/$REPO/releases"
say() { printf '%s\n' "$*"; }
note() { printf ' %s\n' "$*"; }
+# The absolute form of a path under $HOME is mostly the reader's own username,
+# and it wraps the line on a narrow window.
+tilde() {
+ case "$1" in
+ "$HOME"/*) printf '~%s' "${1#"$HOME"}" ;;
+ *) printf '%s' "$1" ;;
+ esac
+}
+
# Every failure names the next step. Someone stuck here has money at risk and no
# way to tell a broken download from a hostile one.
die() {
@@ -45,6 +56,17 @@ fetch() {
curl --proto '=https' --tlsv1.2 -fsSL --retry 3 --retry-connrefused "$1" -o "$2"
}
+# The archive is tens of megabytes and the only step here that takes minutes, so
+# it is the one fetch that keeps curl's meter: `-s` left a single "downloading"
+# line standing unchanged for the whole transfer, which reads as a hang rather
+# than as work. --speed-limit ends a stalled one instead of waiting on it for as
+# long as the OS will — under 1 KB/s for 30s is not a slow link, it is a dead one,
+# and --retry then gets another go at it.
+fetch_archive() {
+ curl --proto '=https' --tlsv1.2 -fSL --retry 3 --retry-connrefused \
+ --speed-limit 1024 --speed-time 30 -# "$1" -o "$2"
+}
+
need curl
need tar
need uname
@@ -215,36 +237,62 @@ esac
TARGET=$(detect_target)
ARCHIVE="tula-v$VERSION-$TARGET.tar.gz"
BASE="https://github.com/$REPO/releases/download/v$VERSION"
+VERSION_DIR="$INSTALL_DIR/versions/$VERSION"
UNVERIFIED=
UNVERIFIED_WHY=
say ""
say "tula $VERSION — $TARGET"
-TMP=$(mktemp -d 2>/dev/null || mktemp -d -t tula)
-trap 'rm -rf "$TMP"' EXIT INT TERM
+# Every version stays on disk under its own number, so a run asking for one that
+# is already there has nothing to fetch. It downloaded and re-verified the whole
+# archive regardless — minutes of it, on a link where that is minutes — to arrive
+# at the file it already had. The launcher and the PATH line are still put right
+# below, because repairing those is the other reason to run this twice.
+#
+# The launcher has to be a symlink already for this to count as "installed", and
+# that is the security of it, not a tidiness check. A directory alone is
+# something anyone who can write under $INSTALL_DIR can put there before tula is
+# ever installed — and a first install used to overwrite whatever it found,
+# where this would adopt it and link it unread. Requiring the link narrows the
+# fast path to a tree this script has already built and verified once; replacing
+# the binary under that link is an attack the download never prevented anyway,
+# since the launcher points at it either way.
+#
+# TULA_FORCE=1 fetches and checks again regardless.
+ALREADY=
+if [ -z "${TULA_FORCE:-}" ] && [ -x "$VERSION_DIR/tula" ] && [ -L "$BIN_DIR/tula" ]; then
+ ALREADY=1
+fi
-note "downloading"
-fetch "$BASE/$ARCHIVE" "$TMP/$ARCHIVE" ||
- die "No build of $VERSION for $TARGET." \
- "Releases: https://github.com/$REPO/releases"
-fetch "$BASE/checksums.txt" "$TMP/checksums.txt" ||
- die "Could not download checksums.txt for $VERSION." \
- "tula will not install a binary it cannot check."
+if [ -z "$ALREADY" ]; then
+ TMP=$(mktemp -d 2>/dev/null || mktemp -d -t tula)
+ trap 'rm -rf "$TMP"' EXIT INT TERM
-note "checking the download matches its published checksum"
-verify_checksum "$TMP/$ARCHIVE" "$TMP/checksums.txt"
+ note "downloading"
+ fetch_archive "$BASE/$ARCHIVE" "$TMP/$ARCHIVE" ||
+ die "No build of $VERSION for $TARGET." \
+ "Releases: https://github.com/$REPO/releases"
+ fetch "$BASE/checksums.txt" "$TMP/checksums.txt" ||
+ die "Could not download checksums.txt for $VERSION." \
+ "tula will not install a binary it cannot check."
-note "checking it was built by $REPO"
-verify_attestation "$TMP/$ARCHIVE"
+ note "checking the download matches its published checksum"
+ verify_checksum "$TMP/$ARCHIVE" "$TMP/checksums.txt"
-VERSION_DIR="$INSTALL_DIR/versions/$VERSION"
-mkdir -p "$VERSION_DIR" "$BIN_DIR"
-tar -xzf "$TMP/$ARCHIVE" -C "$VERSION_DIR" ||
- die "Could not unpack $ARCHIVE." "The download may be truncated; try again."
-[ -f "$VERSION_DIR/tula" ] || die "$ARCHIVE did not contain a tula binary." \
- "Report it: https://github.com/$REPO/issues"
-chmod 755 "$VERSION_DIR/tula"
+ note "checking it was built by $REPO"
+ verify_attestation "$TMP/$ARCHIVE"
+
+ mkdir -p "$VERSION_DIR" "$BIN_DIR"
+ tar -xzf "$TMP/$ARCHIVE" -C "$VERSION_DIR" ||
+ die "Could not unpack $ARCHIVE." "The download may be truncated; try again."
+ [ -f "$VERSION_DIR/tula" ] || die "$ARCHIVE did not contain a tula binary." \
+ "Report it: https://github.com/$REPO/issues"
+ chmod 755 "$VERSION_DIR/tula"
+else
+ note "already installed — nothing to download"
+ mkdir -p "$BIN_DIR"
+fi
# Every version stays on disk under its own number and the launcher is a symlink,
# so moving between them is a link flip rather than a re-download — including
@@ -300,11 +348,14 @@ fi
say ""
if [ -n "$REPLACED" ]; then
say "Installed tula $VERSION, and left your launcher alone."
- note "yours: $LAUNCHER"
- note "this build: $VERSION_DIR/tula"
+ note "yours: $(tilde "$LAUNCHER")"
+ note "this build: $(tilde "$VERSION_DIR/tula")"
note "to switch: ln -sf \"$VERSION_DIR/tula\" \"$LAUNCHER\""
+elif [ -n "$ALREADY" ]; then
+ say "tula $VERSION is already installed at $(tilde "$LAUNCHER")"
+ note "nothing was downloaded — TULA_FORCE=1 fetches and checks it again"
else
- say "Installed tula $VERSION to $LAUNCHER"
+ say "Installed tula $VERSION to $(tilde "$LAUNCHER")"
fi
if [ -n "$UNVERIFIED" ]; then
diff --git a/package.json b/package.json
index e0e8201..8f6d360 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@hsnice16/tula",
- "version": "0.1.0",
+ "version": "0.1.1",
"description": "Your true exposure, what breaks first, and more, across every venue at once.",
"license": "MIT",
"type": "module",
diff --git a/scripts/install-test.sh b/scripts/install-test.sh
index f2a9552..f99f3a8 100755
--- a/scripts/install-test.sh
+++ b/scripts/install-test.sh
@@ -23,6 +23,13 @@ bad() {
fail=$((fail + 1))
}
+# Whether this machine already had tula before the suite started. The leak check
+# at the bottom used to ask only whether ~/.tula existed, which is true on every
+# machine that dogfoods the tool — so the suite failed for anyone who had run the
+# published install line, and passed on CI only because CI never had.
+TREE_BEFORE=0
+[ -e "$HOME/.tula" ] && TREE_BEFORE=1
+
# A release exactly as the workflow lays one out: two archives and a checksums
# file listing both.
RELEASE="$WORK/release"
@@ -311,6 +318,53 @@ else
bad "installing twice leaves one PATH line, wherever the install directory is" "wrote $blocks blocks; $out"
fi
+# A version already on disk is the commonest second run there is — the install
+# line pasted again to fix PATH — and it used to fetch and re-verify the whole
+# archive to arrive at the file it already had. Proving it downloads nothing
+# means taking the archives away: a run that still succeeds cannot have fetched
+# one.
+H="$WORK/h11"
+mkdir -p "$H"
+run "$H" >/dev/null 2>&1
+HOLD="$WORK/hold"
+mkdir -p "$HOLD"
+mv "$RELEASE"/*.tar.gz "$HOLD/"
+out=$(run "$H")
+if [ -x "$H/.tula/versions/9.9.9/tula" ] &&
+ case "$out" in *"already installed"*) true ;; *) false ;; esac; then
+ ok "a version already on disk is not downloaded again"
+else
+ bad "a version already on disk is not downloaded again" "$out"
+fi
+
+# And the escape hatch has to really reach the network, or it is a flag that
+# says it re-checked and did not. With the archives still gone it has nothing to
+# fetch, so it must fail where the run above passed.
+out=$(run "$H" TULA_FORCE=1)
+case "$out" in
+ *"No build of 9.9.9"*) ok "TULA_FORCE=1 fetches again rather than trusting the disk" ;;
+ *) bad "TULA_FORCE=1 fetches again rather than trusting the disk" "$out" ;;
+esac
+mv "$HOLD"/*.tar.gz "$RELEASE/"
+
+# A version directory is not proof of an install. Anyone able to write under the
+# install tree can put one there before tula has ever been installed, and the
+# fast path would otherwise adopt whatever binary it found and link it unread —
+# where a first install used to overwrite it. The launcher being a symlink
+# already is what says this script built the tree.
+H="$WORK/h12"
+mkdir -p "$H/.tula/versions/9.9.9"
+printf '#!/bin/sh\necho planted\n' >"$H/.tula/versions/9.9.9/tula"
+chmod 755 "$H/.tula/versions/9.9.9/tula"
+out=$(run "$H")
+if case "$out" in *"already installed"*) false ;; *) true ;; esac &&
+ ! grep -q planted "$H/.tula/versions/9.9.9/tula"; then
+ ok "a planted version directory is downloaded over, not adopted"
+else
+ bad "a planted version directory is downloaded over, not adopted" "$out"
+fi
+
+
# Nothing above may have touched a profile outside the sandbox. This test edits
# shell config, so a leak is silent, permanent and in someone's real home.
leaked=0
@@ -321,8 +375,14 @@ done
[ "$leaked" -eq 0 ] && ok "writes no shell profile outside the sandbox" ||
bad "writes no shell profile outside the sandbox"
-[ -e "$HOME/.tula" ] && bad "creates no install tree outside the sandbox" ||
- ok "creates no install tree outside the sandbox"
+# Two ways to tell a leak from the developer's own install: the fixture's version
+# is one no real release carries, and a tree that was not there before the suite
+# ran did not get there by itself.
+leaked_tree=0
+[ -e "$HOME/.tula/versions/9.9.9" ] && leaked_tree=1
+[ "$TREE_BEFORE" = 0 ] && [ -e "$HOME/.tula" ] && leaked_tree=1
+[ "$leaked_tree" -eq 0 ] && ok "creates no install tree outside the sandbox" ||
+ bad "creates no install tree outside the sandbox"
echo
echo " $pass passed, $fail failed"
diff --git a/site/app/install/page.tsx b/site/app/install/page.tsx
index fa19a9a..64c2b37 100644
--- a/site/app/install/page.tsx
+++ b/site/app/install/page.tsx
@@ -159,6 +159,11 @@ const CHANNELS: Channel[] = [
command again does the same thing, and is the one that still works when tula will not
start.
+
+ It downloads nothing when the newest release is the one you already have — it says so
+ and relinks, which takes about a second. TULA_FORCE=1 fetches and checks
+ that same version again anyway.
+
{INSTALL_COMMAND}
@@ -400,7 +405,7 @@ export default function Page() {
{
- "curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.1.0/tula-v0.1.0-darwin-arm64.tar.gz\ngh attestation verify tula-v0.1.0-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml"
+ "curl --proto '=https' --tlsv1.2 -fLO https://github.com/hsnice16/tula/releases/download/v0.1.1/tula-v0.1.1-darwin-arm64.tar.gz\ngh attestation verify tula-v0.1.1-darwin-arm64.tar.gz --repo hsnice16/tula --signer-workflow hsnice16/tula/.github/workflows/release.yml"
}
diff --git a/site/components/Session.tsx b/site/components/Session.tsx
index cb14bba..bb8a24d 100644
--- a/site/components/Session.tsx
+++ b/site/components/Session.tsx
@@ -179,10 +179,14 @@ export const Banner = () => (
preserveAspectRatio="xMidYMin meet"
/>
- {`tula ${VERSION}`}
+ {`tula v${VERSION}`}
Your true exposure, what breaks first, and more, across every venue at once.
+ {/* The binary prints the directory the shell was opened in. A frame
+ offered as its output has to carry the line, and the path is the one
+ part of it this picture has to invent. */}
+ ~/trading
Connected: wallet, hyperliquid, aave, kraken
diff --git a/site/lib/site.ts b/site/lib/site.ts
index bee95b5..b0a82bc 100644
--- a/site/lib/site.ts
+++ b/site/lib/site.ts
@@ -10,7 +10,7 @@ export const NAME = 'tula'
* the two disagree and `release-cut.sh` bumps this with them: a frame offered
* as the tool's own output cannot print a release that was never cut.
*/
-export const VERSION = '0.1.0'
+export const VERSION = '0.1.1'
/**
* GA4, for the site alone. Held here rather than read from `process.env`: an
diff --git a/src/core/paths.test.ts b/src/core/paths.test.ts
new file mode 100644
index 0000000..0082596
--- /dev/null
+++ b/src/core/paths.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from 'bun:test'
+import { homedir } from 'node:os'
+import { homeRelative } from './paths.js'
+
+/**
+ * The banner draws the working directory, and Ink writes text verbatim — so a
+ * directory name is a string somebody else may have chosen. Every byte but `/`
+ * and NUL is legal in one, which a cloned repository or an unpacked archive can
+ * carry onto a machine without its owner ever typing it.
+ */
+describe('homeRelative', () => {
+ const ESC = String.fromCharCode(27)
+
+ test('writes a path under home from the tilde', () => {
+ expect(homeRelative(`${homedir()}/Documents/tula`)).toBe('~/Documents/tula')
+ expect(homeRelative(homedir())).toBe('~')
+ })
+
+ test('leaves a path outside home alone', () => {
+ expect(homeRelative('/usr/local/bin')).toBe('/usr/local/bin')
+ })
+
+ // The name is not a prefix of home, and matching on the bare string would
+ // rewrite a sibling directory as though it sat inside it.
+ test('a sibling of home is not inside it', () => {
+ expect(homeRelative(`${homedir()}-backup/x`)).toBe(`${homedir()}-backup/x`)
+ })
+
+ test('an escape sequence in a directory name never reaches the screen', () => {
+ const evil = `/tmp/${ESC}[2K\rtula v9.9.9 CONNECTED: kraken`
+ const out = homeRelative(evil)
+ expect(out).not.toContain(ESC)
+ expect(out).not.toContain('\r')
+ })
+
+ test('a path too long to sit beside the mark is elided from the head', () => {
+ const out = homeRelative(`/x/${'a'.repeat(4096)}/tail`)
+ expect(out.length).toBeLessThanOrEqual(64)
+ expect(out.startsWith('…')).toBe(true)
+ // The tail is what says which directory this is.
+ expect(out.endsWith('/tail')).toBe(true)
+ })
+})
diff --git a/src/core/paths.ts b/src/core/paths.ts
index 6c053c6..1b25cc7 100644
--- a/src/core/paths.ts
+++ b/src/core/paths.ts
@@ -15,3 +15,25 @@ export const configDir = (): string =>
/** The tree `install.sh` writes. `TULA_INSTALL_DIR` is the same name it reads. */
export const installDir = (): string =>
process.env['TULA_INSTALL_DIR'] ?? join(homedir(), '.tula')
+
+/** A banner line sits beside a 7-column mark; past this a path is wrapping. */
+const MAX_PATH = 64
+
+/**
+ * A path as somebody reads it rather than as the filesystem spells it. The
+ * absolute form of anything under $HOME is mostly the reader's own username,
+ * which is noise on screen and the part they would have to redact before
+ * pasting a screenshot into an issue.
+ *
+ * Stripped and capped because a directory name is somebody else's text as much
+ * as a venue's error is: every byte but `/` and NUL is legal in one, ESC
+ * included, and a repository or an archive can carry a directory named to
+ * repaint whatever draws it. The head is what gets elided — a path identifies
+ * itself by its tail.
+ */
+export const homeRelative = (path: string): string => {
+ const home = homedir()
+ const clean = path.replace(/[\p{Cc}\p{Cf}]+/gu, '')
+ const short = clean === home ? '~' : clean.startsWith(`${home}/`) ? `~${clean.slice(home.length)}` : clean
+ return short.length > MAX_PATH ? `…${short.slice(short.length - MAX_PATH + 1)}` : short
+}
diff --git a/src/ui/app.tsx b/src/ui/app.tsx
index e083d7d..0043c55 100644
--- a/src/ui/app.tsx
+++ b/src/ui/app.tsx
@@ -27,6 +27,7 @@ import { pendingUpdate } from '../update/check.js'
import type { Connector } from '../connectors/types.js'
import { TulaError } from '../core/errors.js'
import * as secrets from '../secrets/store.js'
+import { homeRelative } from '../core/paths.js'
import { APP_DESCRIPTION, APP_VERSION, REPO_URL } from '../version.js'
import { ConnectFlow } from './ConnectFlow.js'
import { connectable, type Connectable, type ConnectorCredentials } from '../connectors/types.js'
@@ -391,8 +392,14 @@ export function App({ session, connectors, initialApiKey, initialVenues, agent:
id: -1,
kind: 'banner',
text: [
- `tula ${APP_VERSION}`,
+ // `v` because that is how the tag, the release and every `git` and `npm`
+ // line about this build spell it; the bare number was the odd one out.
+ `tula v${APP_VERSION}`,
APP_DESCRIPTION,
+ // Where the shell was opened. tula reads nothing from it — the book is
+ // the same in any directory — but it is what tells two terminals apart
+ // at a glance, and every other tool in one prints it.
+ homeRelative(process.cwd()),
...(initialVenues.length > 0 ? [`Connected: ${initialVenues.join(', ')}`] : []),
].join('\n'),
}),
diff --git a/src/ui/screen.test.ts b/src/ui/screen.test.ts
index 20a006c..0548088 100644
--- a/src/ui/screen.test.ts
+++ b/src/ui/screen.test.ts
@@ -1027,7 +1027,7 @@ test('an answer that stops for a tool still says it is working', async () => {
}
})
-const BANNER = `tula ${APP_VERSION}`
+const BANNER = `tula v${APP_VERSION}`
/** The mark sits in its own column beside the banner, so the name is not alone on its row. */
const bannerRow = (row: string) => row.trimEnd().endsWith(BANNER)
@@ -1042,8 +1042,14 @@ test('the session opens with a banner, written once', async () => {
const at = rows.findIndex(bannerRow)
expect(rows[at]).toMatch(/▄▄▄ +tula/)
expect(rows[at + 1]).toMatch(/^ +▄▟ {3}▙▄ +Your true exposure/)
- expect(rows[at + 2]?.trim()).toBe('▄▄▄▄▄▄▄')
+ // The mark's last row now shares its line with the working directory, which
+ // is the third banner line and the one that made this block as tall as the
+ // mark rather than one row shorter than it.
+ expect(rows[at + 2]).toMatch(/^ *▄▄▄▄▄▄▄ +[~/]/)
expect(rows[at]?.indexOf('tula')).toBe(rows[at + 1]?.indexOf('Your') ?? -1)
+ expect(rows[at + 1]?.indexOf('Your')).toBe(
+ rows[at + 2]?.search(/[~/]\S*$/) ?? -1,
+ )
// It is a transcript entry, so it scrolls away with the rest rather than
// being redrawn — and a redraw that reissued it would stack a second copy.
await screen.press('/help\r')
diff --git a/src/version.ts b/src/version.ts
index 5006f30..7660a51 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,5 +1,5 @@
export const APP_NAME = 'tula'
-export const APP_VERSION = '0.1.0'
+export const APP_VERSION = '0.1.1'
/**
* SemVer says a hyphen means pre-release, and `release.yml` already reads it