Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ build/
.logs/
release/
release-mock/
.t3
.bnpnecode
.idea/
apps/web/.playwright
apps/web/playwright-report
Expand Down
231 changes: 231 additions & 0 deletions BUILD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# BUILD.md — Personal Desktop Build with GitHub Auto-Updates

This guide walks through building T3 Code as a personal desktop app (`.dmg` on macOS) wired to **your own GitHub repository** for auto-updates. The app will check your repo's Releases on startup and let you download/install updates from inside the app.

> **Replace `OWNER/REPO` everywhere below with your GitHub `username/repo-name`** (e.g. `bnpne/ottawa` or wherever your fork lives).

---

## How auto-update works here

- `electron-updater` is wired into the desktop app (`apps/desktop/src/updates/DesktopUpdates.ts`).
- At **build time**, the `T3CODE_DESKTOP_UPDATE_REPOSITORY` env var is baked into the artifact's `app-update.yml` via `scripts/build-desktop-artifact.ts:514` → `resolveGitHubPublishConfig`.
- At **runtime**, the app checks `https://github.com/OWNER/REPO/releases/latest` for a `latest-mac.yml` (or `latest.yml` / `latest-linux.yml`).
- Update UX is manual: a rocket icon appears in the UI; click once to download, click again to restart & install. No silent auto-install.
- Required Release assets:
- the installer (`.dmg`, `.exe`, or `.AppImage`)
- macOS also needs the `.zip` (Squirrel.Mac uses it for the update payload)
- `latest-mac.yml` / `latest.yml` / `latest-linux.yml` (channel metadata)
- `*.blockmap` (differential downloads)

---

## Prerequisites

1. Bun `^1.3.11`, Node `^24.13.1` (`bun --version`, `node --version`).
2. A GitHub repo you own (fork or otherwise) to host releases — call it `OWNER/REPO`.
3. `gh` CLI installed and authenticated (`gh auth status`). Used to create the GitHub Release and upload assets.
4. (macOS only, optional but recommended) Apple Developer ID certificate if you want updates to install without a Gatekeeper prompt. See "macOS signing caveat" below.

---

## 1. One-time setup

### Make sure your repo has a `main` remote

```bash
# from inside the repo root
git remote -v
# expect: origin [email protected]:OWNER/REPO.git ...
```

If you forked from `pingdotgg/t3code`, add your fork as a remote and push:

```bash
git remote add personal [email protected]:OWNER/REPO.git
git push personal HEAD:main
```

### Install dependencies

```bash
bun install
```

---

## 2. Build the `.dmg` (Apple Silicon)

The build command bakes your repo into the artifact's auto-update config.

```bash
export T3CODE_DESKTOP_UPDATE_REPOSITORY="OWNER/REPO"

# Apple Silicon
bun dist:desktop:dmg:arm64

# OR Intel Mac
bun dist:desktop:dmg:x64

# OR both archs in one DMG (universal)
bun dist:desktop:dmg
```

Other targets if you ever need them:

```bash
bun dist:desktop:linux # Linux x64 AppImage
bun dist:desktop:win # Windows NSIS installer
```

Artifacts land in `./release/`. For an arm64 build at version `0.0.24` you should see:

```
release/
├── T3-Code-0.0.24-arm64.dmg
├── T3-Code-0.0.24-arm64.dmg.blockmap
├── T3-Code-0.0.24-arm64-mac.zip
├── T3-Code-0.0.24-arm64-mac.zip.blockmap
└── latest-mac.yml
```

All of those need to go into the GitHub Release.

> The version comes from `apps/server/package.json` `version` field. Bump it there (e.g. `0.0.25`) before each new build so the updater sees a newer version.

---

## 3. Create the GitHub Release

The version tag must match the version inside the build. With `version = 0.0.24`:

```bash
export VERSION="0.0.24"

git tag "v${VERSION}"
git push origin "v${VERSION}"

gh release create "v${VERSION}" \
--repo OWNER/REPO \
--title "v${VERSION}" \
--notes "Personal build" \
./release/T3-Code-${VERSION}-arm64.dmg \
./release/T3-Code-${VERSION}-arm64.dmg.blockmap \
./release/T3-Code-${VERSION}-arm64-mac.zip \
./release/T3-Code-${VERSION}-arm64-mac.zip.blockmap \
./release/latest-mac.yml
```

> **Important:** the release tag must be in the `vX.Y.Z` format and must **not** be marked as a prerelease, otherwise the `latest` channel won't pick it up. (Tags with suffixes like `-nightly.*` go to the `nightly` channel — keep things on plain `vX.Y.Z` unless you want that.)

---

## 4. Install the app

Open the `.dmg` from `./release/` and drag T3 Code into Applications.

First launch on macOS will be blocked by Gatekeeper because the build is unsigned. Either:

```bash
# Remove the quarantine flag
xattr -dr com.apple.quarantine "/Applications/T3 Code.app"
```

…or right-click the app → **Open** → confirm the warning once.

---

## 5. Shipping an update

When you want to push an update:

1. Bump version in `apps/server/package.json` (e.g. `0.0.24` → `0.0.25`).
2. Commit and push.
3. Rebuild:
```bash
export T3CODE_DESKTOP_UPDATE_REPOSITORY="OWNER/REPO"
bun dist:desktop:dmg:arm64
```
4. Tag and create the Release:
```bash
export VERSION="0.0.25"
git tag "v${VERSION}"
git push origin "v${VERSION}"
gh release create "v${VERSION}" --repo OWNER/REPO --title "v${VERSION}" --notes "" \
./release/T3-Code-${VERSION}-arm64.dmg \
./release/T3-Code-${VERSION}-arm64.dmg.blockmap \
./release/T3-Code-${VERSION}-arm64-mac.zip \
./release/T3-Code-${VERSION}-arm64-mac.zip.blockmap \
./release/latest-mac.yml
```
5. Open the installed T3 Code app. Within the startup check interval it will detect the update and show the rocket icon in the UI. Click it to download, click again to restart & install.

---

## Private-repo auth

If `OWNER/REPO` is private, the desktop app needs a token to read your releases. Set this in the runtime environment **of the installed app** (not the build):

```bash
# in your shell rc / launchd plist / however you launch the app
export T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN="ghp_xxx" # or GH_TOKEN=...
```

The app forwards it as `Authorization: Bearer <token>` on updater HTTP calls (see `docs/release.md:111-113`).

---

## macOS signing caveat (read this if updates fail to install)

Unsigned macOS apps can be **installed manually**, but `electron-updater`'s install step uses Squirrel.Mac, which **requires a code-signed app** to apply updates. With no signing:

- The update will download.
- Clicking "install" will fail silently or kick you back to a manual reinstall.

Options:

1. **Just reinstall manually each time** — simplest. Skip auto-install entirely; download the new `.dmg` from your Releases page.
2. **Self-sign with an ad-hoc identity** — not enough; Squirrel.Mac still rejects ad-hoc.
3. **Use a real Developer ID** — needed for true in-app auto-install. If you have an Apple Developer account, set these env vars before building and pass `--signed`:
```bash
export CSC_LINK="$(base64 < /path/to/cert.p12)"
export CSC_KEY_PASSWORD="..."
export APPLE_API_KEY="$(cat AuthKey_XXX.p8)"
export APPLE_API_KEY_ID="XXX"
export APPLE_API_ISSUER="UUID"
node scripts/build-desktop-artifact.ts --platform mac --target dmg --arch arm64 --signed
```

For a personal-use build, option (1) is the realistic path: auto-update can still **notify** you and **download** the new version even unsigned — you just finish the install by mounting the DMG yourself.

---

## Quick reference: env vars

| Variable | Where | Purpose |
| --- | --- | --- |
| `T3CODE_DESKTOP_UPDATE_REPOSITORY` | build-time | Baked into `app-update.yml`. Format: `owner/repo`. |
| `T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN` | runtime | Token for private repo updater HTTP. |
| `GH_TOKEN` | runtime | Fallback if `T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN` is not set. |
| `T3CODE_DESKTOP_VERSION` | build-time | Override version baked into artifact. |
| `T3CODE_DESKTOP_OUTPUT_DIR` | build-time | Override output dir (defaults to `release/`). |
| `T3CODE_DESKTOP_VERBOSE` | build-time | Stream subprocess stdout for debugging. |

---

## Build commands cheat sheet

```bash
# Local install only — no installer
bun build:desktop
bun start:desktop

# Build a .dmg for personal use (Apple Silicon)
T3CODE_DESKTOP_UPDATE_REPOSITORY=OWNER/REPO bun dist:desktop:dmg:arm64

# Cut a release
gh release create vX.Y.Z --repo OWNER/REPO ./release/*

# Dev mode (Electron + hot reload, not packaged)
bun dev:desktop
```
2 changes: 1 addition & 1 deletion apps/desktop/src/app/DesktopEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ const makeDesktopEnvironment = Effect.fn("desktop.environment.make")(function* (
: input.platform === "darwin"
? path.join(homeDirectory, "Library", "Application Support")
: Option.getOrElse(config.xdgConfigHome, () => path.join(homeDirectory, ".config"));
const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".t3"));
const baseDir = Option.getOrElse(config.t3Home, () => path.join(homeDirectory, ".bnpnecode"));
const rootDir = path.resolve(input.dirname, "../../..");
const appRoot = input.isPackaged ? input.appPath : rootDir;
const branding = resolveDesktopAppBranding({
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1884,6 +1884,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
fallbackErrorMessage: "git worktree add failed",
});

yield* copyGitignoredEnvFiles(input.cwd, worktreePath);

return {
worktree: {
path: worktreePath,
Expand All @@ -1892,6 +1894,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
};
});

const copyGitignoredEnvFiles = Effect.fn("copyGitignoredEnvFiles")(function* (
sourceCwd: string,
worktreePath: string,
) {
const entries: ReadonlyArray<string> = yield* fileSystem
.readDirectory(sourceCwd)
.pipe(Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])));
const envEntries = entries.filter((entry: string) => entry.startsWith(".env"));
for (const entry of envEntries) {
const from = path.join(sourceCwd, entry);
const to = path.join(worktreePath, entry);
const stat = yield* fileSystem
.stat(from)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (!stat || stat.type !== "File") continue;
yield* fileSystem.copyFile(from, to).pipe(Effect.catch(() => Effect.void));
}
});

const fetchPullRequestBranch: GitVcsDriver.GitVcsDriverShape["fetchPullRequestBranch"] =
Effect.fn("fetchPullRequestBranch")(function* (input) {
const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export function resolveEffectiveEnvMode(input: {
if (activeWorktreePath) {
return "local";
}
return draftThreadEnvMode === "worktree" ? "worktree" : "local";
return draftThreadEnvMode === "local" ? "local" : "worktree";
}
return activeWorktreePath ? "worktree" : "local";
}
Expand Down
8 changes: 4 additions & 4 deletions packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,8 @@ exit 1
export const REMOTE_LAUNCH_SCRIPT = `set -eu
@@T3_NODE_ENV_SCRIPT@@
STATE_KEY="$1"
STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"
DEFAULT_SERVER_HOME="$HOME/.t3"
STATE_DIR="$HOME/.bnpnecode/ssh-launch/$STATE_KEY"
DEFAULT_SERVER_HOME="$HOME/.bnpnecode"
DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"
PORT_FILE="$STATE_DIR/port"
PID_FILE="$STATE_DIR/pid"
Expand Down Expand Up @@ -618,8 +618,8 @@ printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGE
`;

export const REMOTE_PAIRING_SCRIPT = `set -eu
STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@"
DEFAULT_SERVER_HOME="$HOME/.t3"
STATE_DIR="$HOME/.bnpnecode/ssh-launch/@@T3_STATE_KEY@@"
DEFAULT_SERVER_HOME="$HOME/.bnpnecode"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSH stop/log scripts use stale .t3 path

High Severity

REMOTE_STOP_SCRIPT and REMOTE_LOG_TAIL_SCRIPT still reference $HOME/.t3/ssh-launch/ while REMOTE_LAUNCH_SCRIPT and REMOTE_PAIRING_SCRIPT (in the same file) were updated to $HOME/.bnpnecode/ssh-launch/. The launch script creates state (PID, port, log files) under .bnpnecode, but the stop script looks for them under .t3, so stopping a remote server and tailing its logs will silently fail to find the correct files.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 574e78a. Configure here.

RUNNER_FILE="$STATE_DIR/run-t3.sh"
mkdir -p "$STATE_DIR"
cat >"$RUNNER_FILE" <<'SH'
Expand Down
4 changes: 2 additions & 2 deletions scripts/dev-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
});

describe("createDevRunnerEnv", () => {
it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () =>
it.effect("defaults T3CODE_HOME to ~/.bnpnecode when not provided", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const env = yield* createDevRunnerEnv({
Expand All @@ -64,7 +64,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
devUrl: undefined,
});

assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".t3"));
assert.equal(env.T3CODE_HOME, path.resolve(NodeOS.homedir(), ".bnpnecode"));
}),
);

Expand Down
2 changes: 1 addition & 1 deletion scripts/dev-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1";
const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const;

export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
path.join(NodeOS.homedir(), ".t3"),
path.join(NodeOS.homedir(), ".bnpnecode"),
);

const MODE_ARGS = {
Expand Down
Loading