Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
123 commits
Select commit Hold shift + click to select a range
4fb04fe
fix(desktop): use the crypto service for screenshot IDs (#16415)
Yash-Singh1 Oct 6, 2026
e6abc01
fix(shared): find versioned JetBrains macOS app bundles (#16246)
Sypher760-gif Oct 6, 2026
9bd1d80
fix(server): OpenCode 2 threads get T3 Code's MCP tools (#16142)
nkoynov Oct 6, 2026
ac8e945
feat(preview): run the browser on the environment server (#15328)
maria-rcks Oct 6, 2026
17c0878
fix: restore service references breaking ci (#16495)
maria-rcks Oct 6, 2026
dff412c
fix(mcp): mark declared tool failures as errors (#15617)
maria-rcks Oct 6, 2026
fd1c338
fix(release): unblock nightly browser tests and cli builds (#16515)
maria-rcks Oct 6, 2026
b38aa18
fix(mcp): preserve thread command rejection reasons (#15627)
maria-rcks Oct 6, 2026
78552a8
chore(deps): upgrade @effect/tsgo to 0.46.1 (#16360)
esthor Oct 6, 2026
eea9f7c
fix(orchestration-v2): show reported subagent models (#14108)
Bil0000 Oct 6, 2026
9503155
fix(web): Apple logo no longer dips below the device host label (#14825)
jakeleventhal Oct 6, 2026
29366ea
fix(web): show subagent effort and speed in hover cards (#13056)
Bil0000 Oct 6, 2026
b83d731
feat(web): reopen closed tabs across the app (#15207)
Bil0000 Oct 6, 2026
8c61e25
fix(web): stop wide ordered list markers from clipping (#16523)
UtkarshUsername Oct 6, 2026
9b2c03e
fix(desktop): build AppImage with the static runtime toolset (fixes l…
Bombatomica64 Oct 6, 2026
7bf6de1
fix(mobile): keep usage-limit notice opaque (#15602)
bjrump Oct 6, 2026
1564aaa
feat(server): GitHub API transport that uses gh only for the token (#…
juliusmarminge Oct 6, 2026
adc3c93
feat(server): pull requests talk to GitHub's API instead of the gh CL…
juliusmarminge Oct 6, 2026
649418f
feat(server): source control, media and discovery use GitHub's API in…
juliusmarminge Oct 6, 2026
9ac8f33
feat: choose the GitHub account per host, save a GitHub token, and fe…
juliusmarminge Oct 6, 2026
1eae9c2
fix(server): Rebase stack moves each layer onto the rebased layer bel…
juliusmarminge Oct 6, 2026
3dfe373
fix(web): diff panel keeps the chosen scope while a turn runs (#16571)
juliusmarminge Oct 6, 2026
78c9fca
fix(desktop): honor the telemetry opt-out from the shell profile (#16…
juliusmarminge Oct 6, 2026
3b44698
docs(marketing): disclose product usage data in the privacy policy (#…
juliusmarminge Oct 6, 2026
115640a
feat(web): note anonymous usage data in onboarding and link the priva…
juliusmarminge Oct 6, 2026
f21d6da
perf(web): diff panel no longer re-renders every file header each tim…
stevan-borus Oct 6, 2026
2f85686
feat(server): every T3 MCP tool declares who may call it (#16335)
juliusmarminge Oct 6, 2026
2c8be58
feat(server): outside agents sign in to the T3 MCP server with OAuth …
juliusmarminge Oct 6, 2026
fbe5df2
feat(web): copy an environment's MCP URL for outside agents (#16337)
juliusmarminge Oct 6, 2026
7bee189
chore(tsconfig): turn off the Schema-over-JSON diagnostic in test fil…
esthor Oct 6, 2026
c9b21b2
chore(review): CodeRabbit gates outside contributors' pull requests (…
esthor Oct 6, 2026
f4f148e
fix(desktop): include Linux package license and app metadata (#16597)
juliusmarminge Oct 6, 2026
8ddf200
fix(server): one failing RPC handler no longer ends the client's othe…
juliusmarminge Oct 6, 2026
13aabf3
fix(contracts): a context record that cannot be encoded no longer fai…
juliusmarminge Oct 6, 2026
d8d037e
fix(web): open pull request row actions on right-click (#16612)
jakeleventhal Oct 6, 2026
876f865
fix(web): show attempted paths in file preview errors (#15628)
maria-rcks Oct 6, 2026
5055de3
fix(vcs): passive sidebar rows stop retaining remote pollers (#15666)
maria-rcks Oct 6, 2026
72d5c32
feat(web): group keybindings settings by area with a page toolbar (#1…
maria-rcks Oct 6, 2026
c5c6f9c
feat(web): stop T3-owned subagents from Lineage (#15211)
Bil0000 Oct 7, 2026
ab28039
feat(web): add fast actions to linked pull requests (#16627)
maria-rcks Oct 7, 2026
7202ba6
feat(web): open right panel tab menu with Mod+T (#15686)
Bil0000 Oct 7, 2026
4b5c604
fix(server): provider sessions clean up when their start is interrupt…
juliusmarminge Oct 7, 2026
517188b
fix(web): show "No project" near the top of the new thread picker (#1…
juliusmarminge Oct 7, 2026
d021f57
refactor(server): instrument WS RPCs in group middleware (#15548)
juliusmarminge Oct 7, 2026
f8ed2a0
chore(deps): upgrade @pierre/diffs to 1.5.2 and @pierre/trees to beta…
juliusmarminge Oct 7, 2026
c4481b9
fix(relay): a host restarting onto a deleted tunnel gets a new one (#…
juliusmarminge Oct 7, 2026
740bda4
fix(server): recover a deleted tunnel when Cloudflare says "Tunnel no…
juliusmarminge Oct 7, 2026
2991331
fix(web): iPhone Duo fold controls follow the phone's orientation (#1…
gabrielelpidio Oct 7, 2026
847c53d
fix(web): keep workspace options when expanding lineage (#16635)
maria-rcks Oct 7, 2026
5886bd8
fix(web): preserve bare anchor placeholders in markdown (#16637)
maria-rcks Oct 7, 2026
bfec238
fix(pi): preserve provider identity in discovered models (#16661)
maria-rcks Oct 7, 2026
e9c3d95
fix(auth): preserve explicitly granted pairing scopes (#9785)
juliusmarminge Oct 7, 2026
28ce9d1
feat(auth): separate environment administration permissions (#9786)
juliusmarminge Oct 7, 2026
2eda8ac
feat(auth): separate source control write permissions (#9787)
juliusmarminge Oct 7, 2026
321c6f9
feat(auth): separate filesystem read and write permissions (#9788)
juliusmarminge Oct 7, 2026
7175edc
feat(auth): separate browser preview control permissions (#9789)
juliusmarminge Oct 7, 2026
57b2418
feat(auth): separate diagnostics and usage permissions (#9790)
juliusmarminge Oct 7, 2026
cc41482
feat(auth): allow passive terminal observation (#9791)
juliusmarminge Oct 7, 2026
365aa87
fix(auth): keep old clients connected across scope changes (#10298)
juliusmarminge Oct 7, 2026
ad582d9
merge: sync upstream pingdotgg/t3code 365aa87982 into fork main 19316…
Oct 7, 2026
10f39eb
feat(server): hosted agents like ChatGPT can sign in to the T3 MCP se…
juliusmarminge Oct 7, 2026
0f4f02a
docs: connect Claude Code, Codex, ChatGPT and bots over MCP (#16741)
juliusmarminge Oct 7, 2026
f60367b
fix(web): thread details card gives titles room to read (#16746)
t3dotgg Oct 7, 2026
4b063a0
fix(mcp): agent HTML pages stop painting slab backgrounds (#16752)
t3dotgg Oct 7, 2026
a183091
fix: composer picks up new project skills without a server restart (#…
juliusmarminge Oct 7, 2026
079e4bc
feat(server): run a project action when a worktree thread settles (#1…
t3dotgg Oct 7, 2026
655dfbe
feat(web): old Claude threads compact on send instead of stacking not…
t3dotgg Oct 7, 2026
453e596
fix(server): settled threads stop polling their pull requests (#16762)
t3dotgg Oct 7, 2026
b77108b
fix(server): stop storing tool image bytes no client reads (#16652)
derektrimm Oct 7, 2026
10395bc
fix(server): status refresh no longer pegs CPU in repos with thousand…
t3dotgg Oct 7, 2026
c93080b
perf(server): background branch lookups share one GitHub query per sw…
t3dotgg Oct 7, 2026
cd41c4a
fix(server): threads settle as soon as a client sees their PR merge (…
t3dotgg Oct 7, 2026
611132c
feat(server,web,mobile): agents see snooze state and link to threads …
t3dotgg Oct 7, 2026
521f5b5
fix(web): replace Lineage timers with Stop on hover (#16791)
Bil0000 Oct 7, 2026
6ecb82d
fix(clients): running subagent cards stay visible after their parent …
juliusmarminge Oct 7, 2026
4d976d1
feat: MCP apps render and run inline in threads (#16236)
juliusmarminge Oct 7, 2026
a8c4802
fix(server): a background Claude subagent's work shows while its pare…
Vantrongs Oct 7, 2026
8d564a3
fix(mobile): hide threads from switched-off environments (#16886)
entity Oct 7, 2026
87c366b
fix(server): a refused Claude turn no longer throws away its session …
SunkenInTime Oct 7, 2026
db46c42
fix(web): onboarding Continue no longer locks on computers that won't…
juliusmarminge Oct 7, 2026
d9068d3
fix(server): worktree cleanup no longer deletes files hidden by showU…
SunkenInTime Oct 7, 2026
93e8640
fix(server): merged-worktree cleanup removes worktrees after squash m…
tris203 Oct 7, 2026
e0201d3
fix(server): free worktrees for terminal thread statuses (#15150)
ANSHSINGH050404 Oct 7, 2026
4ce6a51
fix(server): Windows worktrees with long paths no longer fail or stra…
That1Drifter Oct 7, 2026
41985b4
fix(server): main typechecks again after a test used renamed helpers …
juliusmarminge Oct 7, 2026
f570bd2
fix(server): Claude prompts no longer hang on a uuid the session alre…
juliusmarminge Oct 7, 2026
588d32e
feat(models): add Claude Haiku 5.5 and retire Sonnet 5 and Opus 5 to …
juliusmarminge Oct 7, 2026
ba0ea3d
fix(web): iPhone Duo folds animate, center on the hinge, and keep the…
gabrielelpidio Oct 7, 2026
9f48092
fix(server): Claude 5-series models always run 1M context (#16908)
juliusmarminge Oct 7, 2026
73e87f6
fix(desktop): setup prompts name a t3 that runs on desktop installs (…
juliusmarminge Oct 7, 2026
0678e4e
feat(desktop): install the t3 command from Settings (#16683)
juliusmarminge Oct 7, 2026
a9fb6a8
fix(web): show live names in thread-read activity (#13140)
Bil0000 Oct 7, 2026
468ade3
feat(mobile): adopt v5 navigation and native iPad columns (#16733)
juliusmarminge Oct 7, 2026
8a92ebe
fix(mobile): Android composer picker scrolls past the first four rows…
shivamhwp Oct 7, 2026
5383296
fix(desktop): sign-in and captchas work again in desktop browser tabs…
juliusmarminge Oct 7, 2026
8548961
fix(server): missing project folders no longer log favicon warnings (…
yordis Oct 7, 2026
eba0521
fix(server): unload Codex threads left idle on the shared app-server …
RhysSullivan Oct 7, 2026
26285ab
fix(web): fast typing no longer scrambles text when type-to-focus kic…
otavio Oct 7, 2026
7f3628e
fix(web): simplify workspace card rows (#16823)
Bil0000 Oct 7, 2026
7ac5ec6
fix(web): Copy MCP URL shows up for environments reached over plain h…
SunkenInTime Oct 7, 2026
abbf5d5
fix(web): C#, Java, PHP and 11 other languages get file icons (#16974)
juliusmarminge Oct 7, 2026
95ccbf4
feat(clients): live row shows the agent's latest thought (#16284)
t3dotgg Oct 7, 2026
300f7f9
feat(web): block-level Markdown in the rich text composer (#14677)
chrisdeeming Oct 7, 2026
46dd094
fix(web): center project monograms in settled rows (#16841)
Aforno Oct 7, 2026
3143335
fix(web): cancelling a new citation no longer leaves a stray space (#…
Aforno Oct 7, 2026
ff3a04a
fix(settings): provider updates show live progress instead of a bare …
shivamhwp Oct 8, 2026
14fe015
feat(web): find in diffs with Cmd+F (#14623)
juliusmarminge Oct 8, 2026
08b1222
refactor(server): GitHub services are named for the API they call, no…
juliusmarminge Oct 8, 2026
a21ac5f
refactor(server): GitHub GraphQL batches use variables and share one …
juliusmarminge Oct 8, 2026
42d92ec
refactor(server): GitHub source control reads GitHubApi directly (#16…
juliusmarminge Oct 8, 2026
d720210
refactor(server): GitHub rate limits read the response headers (#16986)
juliusmarminge Oct 8, 2026
83a82a4
chore(review): remove the custom Approvability check (#17018)
esthor Oct 8, 2026
f9e16b3
perf(mobile): show the cached thread list sooner and stop the freeze …
juliusmarminge Oct 8, 2026
9fba209
fix(server): replaying a command no longer freezes the server (#17041)
SunkenInTime Oct 8, 2026
04ad174
fix(server): opening a long thread no longer blocks the server for a …
SunkenInTime Oct 8, 2026
e7fbe26
fix: typecheck after upstream 365aa87982 sync (tsgo nodeBuiltinImport…
Oct 8, 2026
1d968a8
merge: sync upstream pingdotgg/t3code 04ad17425a (55 commits since 36…
Oct 8, 2026
7deceab
merge: fork main 0b3b3d67b3 into upstream sync branch
Oct 8, 2026
7f9d515
fix(web): stories pass awaitingUser to TimelineRowActivityState after…
Oct 8, 2026
b802bb9
style: format sync-resolved BranchToolbar files
Oct 8, 2026
8658201
chore(sync): drop duplicate PreviewBrowser import; move fork additive…
Oct 8, 2026
7beeddb
merge: fork main 701ee1b8a5 into upstream sync branch
Oct 11, 2026
a4d8b5f
fix(server): take upstream 4daec109cd — bind pairing scope check as i…
Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 11 additions & 3 deletions .coderabbit.config.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { defineConfig } from "@coderabbitai/config";
import { defineConfig, type CodeRabbitContext } from "@coderabbitai/config";

export default defineConfig({
// Org members and collaborators merge their own pull requests. On anyone else's, CodeRabbit
// requests changes until its comments are resolved, then approves.
const UNGATED_AUTHORS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const isGated = ({ pr }: CodeRabbitContext) => !UNGATED_AUTHORS.has(pr?.authorAssociation ?? "");

export default defineConfig((ctx) => ({
reviews: {
high_level_summary: false,
review_status: false,
request_changes_workflow: isGated(ctx),
allow_author_approval: !isGated(ctx),
auto_review: {
enabled: true,
},
pre_merge_checks: {
docstrings: { mode: "off" },
override_requested_reviewers_only: isGated(ctx),
},
path_filters: [
// Vendored read-only reference checkouts of upstream Effect and Alchemy
Expand All @@ -34,4 +42,4 @@ export default defineConfig({
],
},
},
});
}));
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ jobs:
# No Electron setup here: `t3` (apps/server) has no Electron dependency
# and none of its tests touch the runtime. Only the non-server `test`
# job, which covers @t3tools/desktop, needs the download.
- name: Install Chromium for server browser integration tests
run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium

- name: Test
env:
T3CODE_TRANSFER_BUDGET_REPORT_PATH: ${{ runner.temp }}/t3code-transfer-budget.md
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,9 @@ jobs:
# No Electron setup here: `t3` (apps/server) has no Electron dependency
# and none of its tests touch the runtime. Only the non-server `test`
# job, which covers @t3tools/desktop, needs the download.
- name: Install Chromium for server browser integration tests
run: node apps/server/node_modules/playwright-core/cli.js install --with-deps --only-shell chromium

- name: Test
run: vp run --filter t3 test --shard ${{ matrix.shard }}/${{ strategy.job-total }}

Expand Down
8 changes: 7 additions & 1 deletion .t3team-additive-guard.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
"apps/server/src/mcp/toolkits/thread/handlers.ts",
"apps/server/src/mcp/toolkits/worktree/handlers.ts",
"apps/server/src/mcp/toolkits/worktree/tools.ts",
"apps/server/src/observability/RpcInstrumentation.ts",
"apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts",
"apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts",
"apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts",
Expand Down Expand Up @@ -184,6 +185,7 @@
"apps/server/src/provider/opencodeRuntime.ts",
"apps/server/src/provider/providerStatusCache.test.ts",
"apps/server/src/provider/providerStatusCache.ts",
"apps/server/src/pullRequest/GitHubPullRequestApi.ts",
"apps/server/src/pullRequest/GitHubPullRequestCli.test.ts",
"apps/server/src/pullRequest/GitHubPullRequestCli.ts",
"apps/server/src/pullRequest/GitHubPullRequestProvider.ts",
Expand Down Expand Up @@ -288,6 +290,7 @@
"apps/web/src/components/chat/BackgroundJobsIndicator.tsx",
"apps/web/src/components/chat/ChatComposer.tsx",
"apps/web/src/components/chat/ComposerCommandMenu.tsx",
"apps/web/src/components/chat/ComposerPendingUserInputPanel.permissions.test.tsx",
"apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx",
"apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx",
"apps/web/src/components/chat/ComposerPrimaryActions.tsx",
Expand Down Expand Up @@ -318,6 +321,8 @@
"apps/web/src/components/composerFooterLayout.ts",
"apps/web/src/components/contextPresentationRegistry.ts",
"apps/web/src/components/files/FileBrowserPanel.tsx",
"apps/web/src/components/onboarding/WelcomeWizard.import.test.tsx",
"apps/web/src/components/onboarding/WelcomeWizard.terminal.test.tsx",
"apps/web/src/components/onboarding/WelcomeWizard.test.tsx",
"apps/web/src/components/onboarding/WelcomeWizard.tsx",
"apps/web/src/components/preview/PreviewPanel.tsx",
Expand Down Expand Up @@ -377,6 +382,7 @@
"apps/web/src/session-logic.test.ts",
"apps/web/src/session-logic.ts",
"apps/web/src/state/query.ts",
"apps/web/src/state/terminalSessionAvailability.test.ts",
"apps/web/src/state/terminalSessions.ts",
"apps/web/src/state/threads.ts",
"apps/web/src/t3team/components/t3team-LocalWorkspaceSidebarRow.tsx",
Expand Down Expand Up @@ -669,7 +675,7 @@
"apps/web/src/t3team/packs/t3team-kit-fixture-pack/pack.json"
],
"baseRef": "upstream/main",
"forkBaselineRef": "t3team/fork-baseline-20261006",
"forkBaselineRef": "t3team/fork-baseline-20261008",
"guardFileRule": "DO NOT EDIT THIS FILE MANUALLY. Update allowlists via controlled migration changes only.",
"locFailThreshold": 200,
"locWarnThreshold": 150,
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ Architecture and its constraints: `docs/internals/overview.md`. Glossary: `docs/
## Taste

- Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb.
- Client mutations use `createEnvironmentRpcCommand`. Add protected methods to `CLIENT_GUARDED_RPC_SCOPES` in contracts and use the command's `permissionAtom` for UI availability. Grants are checked at execution against the destination environment; the server remains authoritative. Keep raw RPC clients inside `rpc/`, and extend the permission behavior tests when adding a protected method.
- Server features are services; transports stay thin. A `ws.ts` RPC handler, HTTP route, or MCP tool decodes input, calls one service method, and maps errors. See [Effect services](docs/internals/effect-services.md).
- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits and the look is a generic concept, add a variant to the component; a look that belongs to one feature stays in that feature's own component, not in `components/ui`. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` fails lint on violations. See [Web UI](docs/internals/web-ui.md).
- Inferred types over annotations. `any` is the enemy.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
- [Project settings](./docs/user/project-settings.md)
- [Appearance preferences](./docs/user/appearance.md)
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md)
- [Keeping app and server in sync](./docs/user/updating.md)
- [Source control integrations](./docs/user/source-control.md)
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
Expand Down
32 changes: 32 additions & 0 deletions apps/desktop/resources/linux/com.t3tools.t3code.metainfo.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.t3tools.t3code</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>T3 Code</name>
<summary>Desktop GUI for coding agents</summary>
<developer id="com.t3tools">
<name>T3 Tools Inc.</name>
</developer>
<description>
<p>
T3 Code is an open-source desktop app for coding agents. Work with your
existing agent subscriptions, review code changes, and run commands in
your projects.
</p>
<p>
Connect from desktop, web, or mobile to continue working remotely.
</p>
</description>
<launchable type="desktop-id">t3code.desktop</launchable>
<icon type="stock">t3code</icon>
<provides>
<binary>t3code</binary>
</provides>
<categories>
<category>Development</category>
</categories>
<url type="homepage">https://t3.codes</url>
<url type="bugtracker">https://github.com/pingdotgg/t3code/issues</url>
<url type="vcs-browser">https://github.com/pingdotgg/t3code</url>
</component>
243 changes: 243 additions & 0 deletions apps/desktop/src/app/DesktopCliCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import * as DesktopCliCommand from "./DesktopCliCommand.ts";
import * as DesktopCliShim from "./DesktopCliShim.ts";
import * as DesktopEnvironment from "./DesktopEnvironment.ts";

const environmentFor = (
path: Path.Path,
input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean },
) =>
DesktopEnvironment.DesktopEnvironment.of({
path,
platform: input.platform ?? "linux",
isPackaged: input.isPackaged ?? true,
homeDirectory: input.home,
baseDir: input.baseDir,
stateDir: path.join(input.baseDir, "userdata"),
serverRoot: "/opt/T3 Code/resources/app.asar",
appImagePath: Option.none(),
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);

/** The service for a packaged app; the launcher is written on Install. */
const commandIn = (
input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean },
spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"],
) =>
Effect.gen(function* () {
const path = yield* Path.Path;
const fs = yield* FileSystem.FileSystem;
const baseDir = input.baseDir ?? path.join(input.home, ".t3");
yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true });
const make = DesktopCliCommand.make.pipe(
Effect.provideService(
DesktopEnvironment.DesktopEnvironment,
environmentFor(path, { ...input, baseDir }),
),
);
return yield* spawner
? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
: make;
});

/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */
const fakePowerShell = (initial: string) => {
const registry = { path: initial, failReads: false, writes: 0 };
const spawner = ChildProcessSpawner.make((command) =>
Effect.sync(() => {
const { options } = command as unknown as {
readonly options: { readonly env?: Record<string, string> };
};
const env = options.env ?? {};
let exitCode = 0;
let stdout = "";
if (env.T3_SET === "1") {
registry.path = env.T3_PATH ?? "";
registry.writes += 1;
} else if (registry.failReads) {
exitCode = 1;
} else {
stdout = Buffer.from(registry.path, "utf8").toString("base64");
}
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(new TextEncoder().encode(stdout)),
stderr: Stream.empty,
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
});
}),
);
return { registry, spawner };
};

it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
it.effect("links the launcher onto PATH and removes only that link", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const command = yield* commandIn({ home });
const link = path.join(home, ".local", "bin", "t3");

expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false });
// Install writes the launcher itself, even when no local backend ever did.
const installed = yield* command.install;
expect(installed.installedPath).toBe(link);
expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3"));
expect((yield* command.install).installedPath).toBe(link);

expect((yield* command.uninstall).installedPath).toBeNull();
expect(yield* fs.exists(link)).toBe(false);
// The launcher itself stays for setup commands.
expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true);
}).pipe(Effect.scoped),
);

it.effect("never replaces or removes a t3 it did not create", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const command = yield* commandIn({ home });
const theirs = path.join(home, ".local", "bin", "t3");
yield* fs.makeDirectory(path.dirname(theirs), { recursive: true });
yield* fs.writeFileString(theirs, "npm's t3\n");
// Even a broken link in the next folder is someone else's.
yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true });
yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3"));

const error = yield* Effect.flip(command.install);
expect(error.message).toContain("Another t3 command is already installed");
yield* command.uninstall;
expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n");
expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone"));
}).pipe(Effect.scoped),
);

it.effect("finds and removes a link left by a previous T3 home", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") });
const link = (yield* before.install).installedPath;

const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") });
expect((yield* after.state).installedPath).toBe(link);
// Installing again points the link at this home's launcher.
expect((yield* after.install).installedPath).toBe(link);
expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3"));
yield* after.uninstall;
expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false);
}).pipe(Effect.scoped),
);

it.effect("does not read a large binary another t3 links to", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
// A big executable that happens to contain the marker text is still not ours.
const binary = path.join(home, "native-t3");
yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`);
yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true });
yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3"));
const command = yield* commandIn({ home });
expect((yield* command.state).installedPath).toBeNull();
yield* command.uninstall;
expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary);
}).pipe(Effect.scoped),
);

it.effect("reports when another t3 earlier on PATH would run instead", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const shadow = path.join(home, "shadow");
yield* fs.makeDirectory(shadow);
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
const previous = process.env.PATH;
process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous)));

const command = yield* commandIn({ home });
const installed = yield* command.install;
expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3"));
expect(installed.onPath).toBe(false);
yield* fs.remove(path.join(shadow, "t3"));
expect((yield* command.state).onPath).toBe(true);
}).pipe(Effect.scoped),
);

it.effect("leaves the Windows PATH alone when it cannot be read", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const home = yield* fs.makeTempDirectoryScoped();
const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools";
const { registry, spawner } = fakePowerShell(userPath);
const command = yield* commandIn({ home, platform: "win32" }, spawner);

registry.failReads = true;
const error = yield* Effect.flip(command.install);
expect(error.message).toContain("left unchanged");
expect(registry).toMatchObject({ path: userPath, writes: 0 });

registry.failReads = false;
const launcherDir = DesktopCliShim.launcherPath(
environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }),
).replace(/[\\/]t3\.cmd$/, "");
yield* command.install;
expect(registry.path).toBe(`${userPath};${launcherDir}`);
yield* command.uninstall;
expect(registry.path).toBe(userPath);
}).pipe(Effect.scoped),
);

it.effect("does not claim or remove a Windows PATH entry the user added", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const home = yield* fs.makeTempDirectoryScoped();
const launcherDir = path.dirname(
DesktopCliShim.launcherPath(
environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }),
),
);
const userPath = `C:\\Tools;${launcherDir}`;
const { registry, spawner } = fakePowerShell(userPath);
const command = yield* commandIn({ home, platform: "win32" }, spawner);

expect((yield* command.state).installedPath).toBeNull();
yield* command.uninstall;
expect(registry.path).toBe(userPath);
}).pipe(Effect.scoped),
);

it.effect("offers nothing for a development build", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const command = yield* commandIn({
home: yield* fs.makeTempDirectoryScoped(),
isPackaged: false,
});
expect((yield* command.state).supported).toBe(false);
}).pipe(Effect.scoped),
);
});
Loading
Loading