Skip to content

Commit 4d48109

Browse files
Merge branch 'main' into andrew/hold-queued-message-edits
2 parents 84c7d93 + 8a92ebe commit 4d48109

258 files changed

Lines changed: 14031 additions & 3649 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ Full docs live in [docs/](./docs). There's no docs site yet.
9494
- [Project settings](./docs/user/project-settings.md)
9595
- [Appearance preferences](./docs/user/appearance.md)
9696
- [Remote access from a phone or another machine](./docs/user/remote-access.md)
97+
- [Connect Claude Code, Codex, ChatGPT and other agents over MCP](./docs/user/outside-agents.md)
9798
- [Keeping app and server in sync](./docs/user/updating.md)
9899
- [Source control integrations](./docs/user/source-control.md)
99100
- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md)
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { expect, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as FileSystem from "effect/FileSystem";
5+
import * as Option from "effect/Option";
6+
import * as Path from "effect/Path";
7+
import * as Sink from "effect/Sink";
8+
import * as Stream from "effect/Stream";
9+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10+
11+
import * as DesktopCliCommand from "./DesktopCliCommand.ts";
12+
import * as DesktopCliShim from "./DesktopCliShim.ts";
13+
import * as DesktopEnvironment from "./DesktopEnvironment.ts";
14+
15+
const environmentFor = (
16+
path: Path.Path,
17+
input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean },
18+
) =>
19+
DesktopEnvironment.DesktopEnvironment.of({
20+
path,
21+
platform: input.platform ?? "linux",
22+
isPackaged: input.isPackaged ?? true,
23+
homeDirectory: input.home,
24+
baseDir: input.baseDir,
25+
stateDir: path.join(input.baseDir, "userdata"),
26+
serverRoot: "/opt/T3 Code/resources/app.asar",
27+
appImagePath: Option.none(),
28+
} as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
29+
30+
/** The service for a packaged app; the launcher is written on Install. */
31+
const commandIn = (
32+
input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean },
33+
spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"],
34+
) =>
35+
Effect.gen(function* () {
36+
const path = yield* Path.Path;
37+
const fs = yield* FileSystem.FileSystem;
38+
const baseDir = input.baseDir ?? path.join(input.home, ".t3");
39+
yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true });
40+
const make = DesktopCliCommand.make.pipe(
41+
Effect.provideService(
42+
DesktopEnvironment.DesktopEnvironment,
43+
environmentFor(path, { ...input, baseDir }),
44+
),
45+
);
46+
return yield* spawner
47+
? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
48+
: make;
49+
});
50+
51+
/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */
52+
const fakePowerShell = (initial: string) => {
53+
const registry = { path: initial, failReads: false, writes: 0 };
54+
const spawner = ChildProcessSpawner.make((command) =>
55+
Effect.sync(() => {
56+
const { options } = command as unknown as {
57+
readonly options: { readonly env?: Record<string, string> };
58+
};
59+
const env = options.env ?? {};
60+
let exitCode = 0;
61+
let stdout = "";
62+
if (env.T3_SET === "1") {
63+
registry.path = env.T3_PATH ?? "";
64+
registry.writes += 1;
65+
} else if (registry.failReads) {
66+
exitCode = 1;
67+
} else {
68+
stdout = Buffer.from(registry.path, "utf8").toString("base64");
69+
}
70+
return ChildProcessSpawner.makeHandle({
71+
pid: ChildProcessSpawner.ProcessId(1),
72+
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
73+
isRunning: Effect.succeed(false),
74+
kill: () => Effect.void,
75+
unref: Effect.succeed(Effect.void),
76+
stdin: Sink.drain,
77+
stdout: Stream.make(new TextEncoder().encode(stdout)),
78+
stderr: Stream.empty,
79+
all: Stream.empty,
80+
getInputFd: () => Sink.drain,
81+
getOutputFd: () => Stream.empty,
82+
});
83+
}),
84+
);
85+
return { registry, spawner };
86+
};
87+
88+
it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
89+
it.effect("links the launcher onto PATH and removes only that link", () =>
90+
Effect.gen(function* () {
91+
const fs = yield* FileSystem.FileSystem;
92+
const path = yield* Path.Path;
93+
const home = yield* fs.makeTempDirectoryScoped();
94+
const command = yield* commandIn({ home });
95+
const link = path.join(home, ".local", "bin", "t3");
96+
97+
expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false });
98+
// Install writes the launcher itself, even when no local backend ever did.
99+
const installed = yield* command.install;
100+
expect(installed.installedPath).toBe(link);
101+
expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3"));
102+
expect((yield* command.install).installedPath).toBe(link);
103+
104+
expect((yield* command.uninstall).installedPath).toBeNull();
105+
expect(yield* fs.exists(link)).toBe(false);
106+
// The launcher itself stays for setup commands.
107+
expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true);
108+
}).pipe(Effect.scoped),
109+
);
110+
111+
it.effect("never replaces or removes a t3 it did not create", () =>
112+
Effect.gen(function* () {
113+
const fs = yield* FileSystem.FileSystem;
114+
const path = yield* Path.Path;
115+
const home = yield* fs.makeTempDirectoryScoped();
116+
const command = yield* commandIn({ home });
117+
const theirs = path.join(home, ".local", "bin", "t3");
118+
yield* fs.makeDirectory(path.dirname(theirs), { recursive: true });
119+
yield* fs.writeFileString(theirs, "npm's t3\n");
120+
// Even a broken link in the next folder is someone else's.
121+
yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true });
122+
yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3"));
123+
124+
const error = yield* Effect.flip(command.install);
125+
expect(error.message).toContain("Another t3 command is already installed");
126+
yield* command.uninstall;
127+
expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n");
128+
expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone"));
129+
}).pipe(Effect.scoped),
130+
);
131+
132+
it.effect("finds and removes a link left by a previous T3 home", () =>
133+
Effect.gen(function* () {
134+
const fs = yield* FileSystem.FileSystem;
135+
const path = yield* Path.Path;
136+
const home = yield* fs.makeTempDirectoryScoped();
137+
const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") });
138+
const link = (yield* before.install).installedPath;
139+
140+
const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") });
141+
expect((yield* after.state).installedPath).toBe(link);
142+
// Installing again points the link at this home's launcher.
143+
expect((yield* after.install).installedPath).toBe(link);
144+
expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3"));
145+
yield* after.uninstall;
146+
expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
147+
expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false);
148+
}).pipe(Effect.scoped),
149+
);
150+
151+
it.effect("does not read a large binary another t3 links to", () =>
152+
Effect.gen(function* () {
153+
const fs = yield* FileSystem.FileSystem;
154+
const path = yield* Path.Path;
155+
const home = yield* fs.makeTempDirectoryScoped();
156+
// A big executable that happens to contain the marker text is still not ours.
157+
const binary = path.join(home, "native-t3");
158+
yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`);
159+
yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true });
160+
yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3"));
161+
const command = yield* commandIn({ home });
162+
expect((yield* command.state).installedPath).toBeNull();
163+
yield* command.uninstall;
164+
expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary);
165+
}).pipe(Effect.scoped),
166+
);
167+
168+
it.effect("reports when another t3 earlier on PATH would run instead", () =>
169+
Effect.gen(function* () {
170+
const fs = yield* FileSystem.FileSystem;
171+
const path = yield* Path.Path;
172+
const home = yield* fs.makeTempDirectoryScoped();
173+
const shadow = path.join(home, "shadow");
174+
yield* fs.makeDirectory(shadow);
175+
yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
176+
const previous = process.env.PATH;
177+
process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
178+
yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous)));
179+
180+
const command = yield* commandIn({ home });
181+
const installed = yield* command.install;
182+
expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3"));
183+
expect(installed.onPath).toBe(false);
184+
yield* fs.remove(path.join(shadow, "t3"));
185+
expect((yield* command.state).onPath).toBe(true);
186+
}).pipe(Effect.scoped),
187+
);
188+
189+
it.effect("leaves the Windows PATH alone when it cannot be read", () =>
190+
Effect.gen(function* () {
191+
const fs = yield* FileSystem.FileSystem;
192+
const home = yield* fs.makeTempDirectoryScoped();
193+
const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools";
194+
const { registry, spawner } = fakePowerShell(userPath);
195+
const command = yield* commandIn({ home, platform: "win32" }, spawner);
196+
197+
registry.failReads = true;
198+
const error = yield* Effect.flip(command.install);
199+
expect(error.message).toContain("left unchanged");
200+
expect(registry).toMatchObject({ path: userPath, writes: 0 });
201+
202+
registry.failReads = false;
203+
const launcherDir = DesktopCliShim.launcherPath(
204+
environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }),
205+
).replace(/[\\/]t3\.cmd$/, "");
206+
yield* command.install;
207+
expect(registry.path).toBe(`${userPath};${launcherDir}`);
208+
yield* command.uninstall;
209+
expect(registry.path).toBe(userPath);
210+
}).pipe(Effect.scoped),
211+
);
212+
213+
it.effect("does not claim or remove a Windows PATH entry the user added", () =>
214+
Effect.gen(function* () {
215+
const fs = yield* FileSystem.FileSystem;
216+
const path = yield* Path.Path;
217+
const home = yield* fs.makeTempDirectoryScoped();
218+
const launcherDir = path.dirname(
219+
DesktopCliShim.launcherPath(
220+
environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }),
221+
),
222+
);
223+
const userPath = `C:\\Tools;${launcherDir}`;
224+
const { registry, spawner } = fakePowerShell(userPath);
225+
const command = yield* commandIn({ home, platform: "win32" }, spawner);
226+
227+
expect((yield* command.state).installedPath).toBeNull();
228+
yield* command.uninstall;
229+
expect(registry.path).toBe(userPath);
230+
}).pipe(Effect.scoped),
231+
);
232+
233+
it.effect("offers nothing for a development build", () =>
234+
Effect.gen(function* () {
235+
const fs = yield* FileSystem.FileSystem;
236+
const command = yield* commandIn({
237+
home: yield* fs.makeTempDirectoryScoped(),
238+
isPackaged: false,
239+
});
240+
expect((yield* command.state).supported).toBe(false);
241+
}).pipe(Effect.scoped),
242+
);
243+
});

0 commit comments

Comments
 (0)