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
19 changes: 17 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
97 changes: 74 additions & 23 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
64 changes: 62 additions & 2 deletions scripts/install-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
7 changes: 6 additions & 1 deletion site/app/install/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
</p>
<p className="mb-4 text-dim">
It downloads nothing when the newest release is the one you already have — it says so
and relinks, which takes about a second. <Code>TULA_FORCE=1</Code> fetches and checks
that same version again anyway.
</p>
<Terminal title="update">{INSTALL_COMMAND}</Terminal>
</Step>

Expand Down Expand Up @@ -400,7 +405,7 @@ export default function Page() {
</div>
<Terminal title="verify">
{
"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"
}
</Terminal>
<p className="mt-4 text-dim">
Expand Down
6 changes: 5 additions & 1 deletion site/components/Session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,10 +179,14 @@ export const Banner = () => (
preserveAspectRatio="xMidYMin meet"
/>
<span className="min-w-0">
<span className="block font-bold" style={{ color: TUI.accent }}>{`tula ${VERSION}`}</span>
<span className="block font-bold" style={{ color: TUI.accent }}>{`tula v${VERSION}`}</span>
<span className="block text-dim">
Your true exposure, what breaks first, and more, across every venue at once.
</span>
{/* 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. */}
<span className="block text-dim">~/trading</span>
<span className="block text-dim">Connected: wallet, hyperliquid, aave, kraken</span>
</span>
</span>
Expand Down
Loading