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
10 changes: 5 additions & 5 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,9 +338,9 @@ describe("ADE CLI", () => {
});

expect(graph).toContain("ADE lanes");
expect(graph).toContain("\\- main [main]");
expect(graph).toContain("|- child [feature]");
expect(graph).toContain("\\- sibling [feature-2]");
expect(graph).toContain("\\- main (id: main) [main]");
expect(graph).toContain("|- child (id: child) [feature]");
expect(graph).toContain("\\- sibling (id: sibling) [feature-2]");
});

it("accepts --option=value syntax equivalently to --option value", () => {
Expand Down Expand Up @@ -782,7 +782,7 @@ describe("ADE CLI", () => {
expect(summarized).toMatchObject({
lanes: expect.any(Array),
});
expect((summarized as any).visual).toContain("\\- main [main]");
expect((summarized as any).visual).toContain("\\- child [feature]");
expect((summarized as any).visual).toContain("\\- main (id: main) [main]");
expect((summarized as any).visual).toContain("\\- child (id: child) [feature]");
});
});
3 changes: 2 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2646,8 +2646,9 @@ function renderLaneGraph(result: unknown): string {
const branch = asString(lane.branchRef) ?? "";
const status = asString(lane.status) ?? "";
const archived = asString(lane.archivedAt) ? " archived" : "";
lines.push(`${prefix}${isLast ? "\\- " : "|- "}${name}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`);
const id = asString(lane.id);
const idSuffix = id ? ` (id: ${id})` : "";
lines.push(`${prefix}${isLast ? "\\- " : "|- "}${name}${idSuffix}${branch ? ` [${branch}]` : ""}${status ? ` ${status}` : ""}${archived}`);
const children = id ? byParent.get(id) ?? [] : [];
children.forEach((child, index) => visit(child, `${prefix}${isLast ? " " : "| "}`, index === children.length - 1));
};
Expand Down
207 changes: 207 additions & 0 deletions apps/desktop/src/main/services/cli/adeCliService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,213 @@ describe("createAdeCliService", () => {
}
});

it("adds the user install dir to the shell profile when installing Terminal access", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
const packagedCommandPath = path.join(packagedBinDir, "ade");
const installerPath = path.join(resourcesPath, "ade-cli", "install-path.sh");
writeExecutable(packagedCommandPath);
writeExecutable(installerPath);
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
logger: logger() as any,
});

const result = await service.installForUser();
const profilePath = path.join(home, ".zshrc");
const profile = fs.readFileSync(profilePath, "utf8");

expect(result.ok).toBe(true);
expect(result.message).toContain(`added ${path.join(home, ".local", "bin")} to ${profilePath}`);
expect(profile).toContain("# ADE CLI");
expect(profile).toContain('export PATH="$HOME/.local/bin:$PATH"');
});

it("writes to ~/.bashrc when SHELL is bash", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
writeExecutable(path.join(packagedBinDir, "ade"));
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/usr/local/bin/bash", PATH: "/usr/bin:/bin" },
logger: logger() as any,
});

const result = await service.installForUser();
const profilePath = path.join(home, ".bashrc");

expect(result.ok).toBe(true);
expect(result.message).toContain(profilePath);
expect(fs.readFileSync(profilePath, "utf8")).toContain('export PATH="$HOME/.local/bin:$PATH"');
});

it("falls back to ~/.profile when SHELL is unrecognized", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
writeExecutable(path.join(packagedBinDir, "ade"));
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/usr/bin/nu", PATH: "/usr/bin:/bin" },
logger: logger() as any,
});

const result = await service.installForUser();
const profilePath = path.join(home, ".profile");

expect(result.ok).toBe(true);
expect(result.message).toContain(profilePath);
expect(fs.readFileSync(profilePath, "utf8")).toContain('export PATH="$HOME/.local/bin:$PATH"');
});

it("writes fish-syntax PATH update to ~/.config/fish/config.fish for fish shell", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
writeExecutable(path.join(packagedBinDir, "ade"));
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/usr/bin/fish", PATH: "/usr/bin:/bin" },
logger: logger() as any,
});

const result = await service.installForUser();
const profilePath = path.join(home, ".config", "fish", "config.fish");

expect(result.ok).toBe(true);
expect(result.message).toContain(profilePath);
const profile = fs.readFileSync(profilePath, "utf8");
expect(profile).toContain("# ADE CLI");
expect(profile).toContain("fish_add_path -gP $HOME/.local/bin");
expect(profile).not.toContain("export PATH=");
});

it("skips the shell-profile write when the install dir is already on PATH", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
writeExecutable(path.join(packagedBinDir, "ade"));
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");
const targetDir = path.join(home, ".local", "bin");
// Simulate an ade binary already at the install location so getStatus
// reports it as installed once PATH contains targetDir.
writeExecutable(path.join(targetDir, "ade"));

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/bin/zsh", PATH: `${targetDir}:/usr/bin:/bin` },
logger: logger() as any,
});

const result = await service.installForUser();
const profilePath = path.join(home, ".zshrc");

expect(result.ok).toBe(true);
expect(result.message).toBe("Installed ade for Terminal access.");
expect(fs.existsSync(profilePath)).toBe(false);
});

it("does not append the PATH line twice when the marker is already present", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
writeExecutable(path.join(packagedBinDir, "ade"));
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");

const profilePath = path.join(home, ".zshrc");
const seeded = "# previous user content\n\n# ADE CLI\nexport PATH=\"$HOME/.local/bin:$PATH\"\n";
fs.mkdirSync(home, { recursive: true });
fs.writeFileSync(profilePath, seeded);

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
logger: logger() as any,
});

const result = await service.installForUser();

expect(result.ok).toBe(true);
expect(result.message).toContain(profilePath);
expect(result.message).toContain("PATH entry already present");
expect(result.message).not.toMatch(/and added .* to /);
// Profile contents are unchanged — exactly one ADE CLI marker, exactly one PATH line.
const profile = fs.readFileSync(profilePath, "utf8");
expect(profile).toBe(seeded);
expect(profile.match(/# ADE CLI/g)?.length).toBe(1);
});

it("inserts a leading newline when the existing profile has no trailing newline", async () => {
const root = makeTempRoot();
const home = path.join(root, "home");
const resourcesPath = path.join(root, "Resources");
const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin");
writeExecutable(path.join(packagedBinDir, "ade"));
writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.sh"));
fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n");

const profilePath = path.join(home, ".zshrc");
fs.mkdirSync(home, { recursive: true });
fs.writeFileSync(profilePath, "alias foo=bar"); // no trailing newline

const service = createAdeCliService({
isPackaged: true,
resourcesPath,
userDataPath: path.join(root, "user-data"),
appExecutablePath: path.join(root, "ADE.app", "Contents", "MacOS", "ADE"),
env: { HOME: home, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
logger: logger() as any,
});

const result = await service.installForUser();
expect(result.ok).toBe(true);

const profile = fs.readFileSync(profilePath, "utf8");
expect(profile.startsWith("alias foo=bar\n")).toBe(true);
expect(profile).toContain("\n# ADE CLI\n");
expect(profile).toContain('export PATH="$HOME/.local/bin:$PATH"\n');
});

it("creates a dev shim under userData without changing global PATH", () => {
const root = makeTempRoot();
const repoRoot = path.join(root, "repo");
Expand Down
62 changes: 57 additions & 5 deletions apps/desktop/src/main/services/cli/adeCliService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,58 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths {
};
}

function installTargetPath(): string {
function homeDir(env: NodeJS.ProcessEnv = process.env): string {
return env.HOME?.trim() || os.homedir();
}

function installTargetPath(env: NodeJS.ProcessEnv = process.env): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA?.trim() || path.join(os.homedir(), "AppData", "Local");
const localAppData = env.LOCALAPPDATA?.trim() || path.join(homeDir(env), "AppData", "Local");
return path.join(localAppData, "ADE", "bin", "ade.cmd");
}
return path.join(os.homedir(), ".local", "bin", "ade");
return path.join(homeDir(env), ".local", "bin", "ade");
}

type ShellProfile = { path: string; flavor: "posix" | "fish" };

function shellProfilePath(env: NodeJS.ProcessEnv = process.env): ShellProfile {
const shell = env.SHELL?.trim() ?? "";
const home = homeDir(env);
if (shell.endsWith("zsh")) return { path: path.join(home, ".zshrc"), flavor: "posix" };
if (shell.endsWith("bash")) return { path: path.join(home, ".bashrc"), flavor: "posix" };
if (shell.endsWith("fish")) return { path: path.join(home, ".config", "fish", "config.fish"), flavor: "fish" };
return { path: path.join(home, ".profile"), flavor: "posix" };
}

function shellPathEntry(targetDir: string, env: NodeJS.ProcessEnv = process.env): string {
const home = homeDir(env);
const relativeToHome = path.relative(home, targetDir);
if (relativeToHome && !relativeToHome.startsWith("..") && !path.isAbsolute(relativeToHome)) {
return `$HOME/${relativeToHome.split(path.sep).join("/")}`;
}
return targetDir;
}

type ShellPathResult = { profilePath: string; modified: boolean };

function ensureUserBinOnShellPath(
targetDir: string,
env: NodeJS.ProcessEnv = process.env,
): ShellPathResult | null {
if (process.platform === "win32" || pathContainsDir(getPathEnvValue(env), targetDir)) return null;
const profile = shellProfilePath(env);
const entry = shellPathEntry(targetDir, env);
const marker = "# ADE CLI";
const line =
profile.flavor === "fish" ? `fish_add_path -gP ${entry}` : `export PATH="${entry}:$PATH"`;
const existing = fs.existsSync(profile.path) ? fs.readFileSync(profile.path, "utf8") : "";
if (existing.includes(marker) || existing.includes(line) || existing.includes(targetDir)) {
return { profilePath: profile.path, modified: false };
}
const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
fs.mkdirSync(path.dirname(profile.path), { recursive: true });
fs.appendFileSync(profile.path, `${prefix}\n${marker}\n${line}\n`);
return { profilePath: profile.path, modified: true };
}

function statusMessage(args: {
Expand Down Expand Up @@ -466,7 +512,7 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) {

const getStatus = async (): Promise<AdeCliStatus> => {
const terminalCommandPath = resolveCommandOnPath("ade", hostPathSnapshot, envSnapshot);
const targetPath = installTargetPath();
const targetPath = installTargetPath(envSnapshot);
const targetDir = path.dirname(targetPath);
const terminalInstalled = Boolean(terminalCommandPath);
const bundledAvailable = Boolean(resolved.commandPath && isExecutable(resolved.commandPath));
Expand Down Expand Up @@ -518,10 +564,16 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) {
if (result.status !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || "ADE CLI installer failed.");
}
const targetDir = path.dirname(installTargetPath(envSnapshot));
const profileResult = ensureUserBinOnShellPath(targetDir, envSnapshot);
const status = await getStatus();
return {
ok: true,
message: status.installTargetDirOnPath
message: profileResult
? profileResult.modified
? `Installed ade for Terminal access and added ${targetDir} to ${profileResult.profilePath}. Open a new terminal or source that file.`
: `Installed ade for Terminal access. PATH entry already present in ${profileResult.profilePath}; open a new terminal or source that file.`
: status.installTargetDirOnPath
? "Installed ade for Terminal access."
: `Installed ade at ${status.installTargetPath}. Add ${path.dirname(status.installTargetPath)} to PATH if your shell cannot find it.`,
status,
Expand Down
21 changes: 18 additions & 3 deletions apps/desktop/src/renderer/components/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
listContextDocsByHealth,
} from "../context/contextShared";
import { disposeTerminalRuntimesForProjectChange } from "../terminals/TerminalView";
import { buildPrsRouteSearch, type PrDetailRouteTab } from "../prs/prsRouteState";

type PrToast = {
id: string;
Expand Down Expand Up @@ -1234,9 +1235,23 @@ export function AppShell({ children }: { children: React.ReactNode }) {
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border/60 bg-transparent px-3 text-[11px] font-medium text-fg/85 transition-colors hover:border-fg/20 hover:bg-fg/[0.04] hover:text-fg"
onClick={() => {
selectLane(toast.event.laneId);
setLaneInspectorTab(toast.event.laneId, "merge");
window.location.hash = `#/lanes?laneId=${encodeURIComponent(toast.event.laneId)}&focus=single&inspectorTab=merge`;
let detailTab: PrDetailRouteTab | null = null;
if (toast.event.kind === "checks_failing") {
detailTab = "checks";
} else if (
toast.event.kind === "changes_requested" ||
toast.event.kind === "review_requested"
) {
detailTab = "activity";
}
const search = buildPrsRouteSearch({
activeTab: "normal",
selectedPrId: toast.event.prId,
selectedQueueGroupId: null,
selectedRebaseItemId: null,
detailTab,
});
navigate(`/prs${search}`);
dismissPrToast(toast.id);
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
LABEL_STYLE,
primaryButton,
} from "../lanes/laneDesignTokens";
import { AdeCliSection } from "./AdeCliSection";

const sectionLabelStyle: React.CSSProperties = {
...LABEL_STYLE,
Expand Down Expand Up @@ -103,6 +104,11 @@ export function GeneralSection() {
</div>
</section>

<section>
<div style={sectionLabelStyle}>ADE CLI</div>
<AdeCliSection compact />
</section>

<section>
<div style={sectionLabelStyle}>AI MODE</div>
<div style={{ ...cardStyle(), display: "flex", flexDirection: "column", gap: 12 }}>
Expand Down
Loading
Loading