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
80 changes: 76 additions & 4 deletions apps/cli/src/__tests__/context-enable-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe("context enable v3 command", () => {
expect(mocks.buildHandoff).not.toHaveBeenCalled();
});

it("omits an apply command for an unavailable directory choice", async () => {
it("omits an unavailable directory choice and its after-fingerprint", async () => {
mocks.inspectLocation.mockReturnValue({
project: { kind: "pathless" },
directory: null,
Expand All @@ -196,14 +196,72 @@ describe("context enable v3 command", () => {
expect(output.result).toHaveBeenCalledWith(
expect.objectContaining({
plan: expect.objectContaining({
choices: expect.arrayContaining([
expect.objectContaining({ kind: "directory", available: false, applyCommand: null }),
planId: expect.stringMatching(
new RegExp(`^v2\\.[0-9a-f]{64}\\.${"a".repeat(64)}\\.[0-9a-f]{64}\\.-\\.[0-9a-f]{64}$`, "u"),
),
choices: [
expect.objectContaining({ kind: "global", applyCommand: expect.stringContaining(" --pathless ") }),
expect.objectContaining({ kind: "session", applyCommand: expect.stringContaining(" --pathless ") }),
]),
],
}),
}),
);
expect(mocks.fingerprintAfter).toHaveBeenCalledTimes(1);
});

it("keeps canonical project identity in managed-worktree global and session commands", async () => {
mocks.inspectLocation.mockReturnValue({
project,
directory: project.root,
directoryAvailable: false,
temporaryDirectory: true,
warning: "This looks like a Codex temporary directory.",
});
await runContextEnable(context({ plan: true }));
const result = output.result.mock.calls[0]?.[0] as {
plan: { choices: Array<{ kind: string; applyCommand: string }> };
};
expect(result.plan.choices.map((choice) => choice.kind)).toEqual(["global", "session"]);
expect(result.plan.choices.every((choice) => choice.applyCommand.includes(" --project-root '/work/repo' "))).toBe(
true,
);
});

it("rejects a manually constructed directory apply when the choice is hidden", async () => {
mocks.inspectLocation.mockReturnValue({
project,
directory: project.root,
directoryAvailable: false,
temporaryDirectory: true,
warning: null,
});
const planId = await createPlanId();
await expect(runContextEnable(context({ scope: "directory", planId, yes: true }))).rejects.toMatchObject({
code: "CONTEXT_DIRECTORY_UNAVAILABLE",
});
expect(mocks.enableOperation).not.toHaveBeenCalled();
});

it("invalidates the plan when directory availability changes", async () => {
mocks.inspectLocation.mockReturnValue({
project,
directory: project.root,
directoryAvailable: false,
temporaryDirectory: true,
warning: null,
});
const planId = await createPlanId();
mocks.inspectLocation.mockReturnValue({
project,
directory: project.root,
directoryAvailable: true,
temporaryDirectory: false,
warning: null,
});
await expect(runContextEnable(context({ scope: "global", planId, yes: true }))).rejects.toMatchObject({
code: "CONTEXT_ENABLE_PLAN_CHANGED",
});
expect(mocks.enableOperation).not.toHaveBeenCalled();
});

it("renders exact apply commands in the human-readable plan", async () => {
Expand All @@ -215,6 +273,20 @@ describe("context enable v3 command", () => {
expect(statusRows).toContain("Next: Choose one scope, then run its exact apply command unchanged.");
});

it("does not render a hidden directory choice in the human-readable plan", async () => {
mocks.inspectLocation.mockReturnValue({
project,
directory: project.root,
directoryAvailable: false,
temporaryDirectory: true,
warning: "This looks like a Codex temporary directory.",
});
await runContextEnable({ ...context({ plan: true }), options: { json: false, debug: false, quiet: false } });
const statusRows = output.status.mock.calls.map(([label, value]) => `${label}: ${value}`).join("\n");
expect(statusRows).not.toContain("This directory:");
expect(statusRows.match(/Apply command:/gu)).toHaveLength(2);
});

it("pins non-dev apply commands to the portable executable without quoting away tilde expansion", async () => {
mocks.channelConfig.channel = "staging";
mocks.channelConfig.binName = "first-tree-staging";
Expand Down
105 changes: 104 additions & 1 deletion apps/cli/src/__tests__/context-project-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
classifyCodexManagedWorktreePath,
classifyCodexProjectlessPath,
inspectContextClientPreflight,
inspectContextSetupLocation,
Expand Down Expand Up @@ -84,7 +85,7 @@ describe("Context project resolver", () => {
expect(resolution).toMatchObject({
project: { kind: "path", root: scratch },
directory: scratch,
directoryAvailable: true,
directoryAvailable: false,
temporaryDirectory: true,
});
} finally {
Expand Down Expand Up @@ -204,6 +205,91 @@ describe("Context project resolver", () => {
});
});

it("treats Claude setup as pathless when CLAUDE_PROJECT_DIR is missing or invalid", () => {
const cwd = mkdtempSync(join(tmpdir(), "claude-setup-cwd-"));
withSignedIn(() => {
expect(inspectContextSetupLocation("claude-code", { cwd, env: {} })).toMatchObject({
project: { kind: "pathless" },
directory: null,
directoryAvailable: false,
});
expect(
inspectContextSetupLocation("claude-code", {
cwd,
env: { CLAUDE_PROJECT_DIR: join(cwd, "missing") },
}),
).toMatchObject({ project: { kind: "pathless" }, directoryAvailable: false });
});
});

it("uses valid Claude project roots and preserves an explicit setup root", () => {
const root = mkdtempSync(join(tmpdir(), "claude-setup-root-"));
const cwd = join(root, "nested");
mkdirSync(cwd);
withSignedIn(() => {
expect(inspectContextSetupLocation("claude-code", { cwd, env: { CLAUDE_PROJECT_DIR: root } })).toMatchObject({
project: { kind: "path", root },
directory: root,
directoryAvailable: true,
});
expect(inspectContextSetupLocation("claude-code", { projectRoot: cwd, env: {} })).toMatchObject({
project: { kind: "path", root: cwd },
directoryAvailable: true,
});
});
});

it.each([
["darwin", "/Users/alice/.codex/worktrees/9aad/first-tree", "/Users/alice", {}, true],
["darwin", "/Users/alice/.codex/worktrees/9aad/first-tree/packages/cli", "/Users/alice", {}, true],
["darwin", "/Users/alice/.codex/worktrees/9aad", "/Users/alice", {}, false],
["darwin", "/Users/alice/.codex/worktrees-copy/9aad/first-tree", "/Users/alice", {}, false],
["win32", "C:\\Users\\Alice\\.codex\\WORKTREES\\9AAD\\first-tree", "C:\\Users\\Alice", {}, true],
["darwin", "/opt/codex/worktrees/9aad/first-tree", "/Users/alice", { CODEX_HOME: "/opt/codex" }, true],
] as const)("classifies Codex managed worktree path %s %s", (platform, cwd, home, env, expected) => {
expect(classifyCodexManagedWorktreePath(cwd, env, { platform, home })).toBe(expected);
});

it("hides directory setup for a canonical Codex managed worktree without changing project identity", () => {
const root = mkdtempSync(join(tmpdir(), "codex-managed-worktree-"));
const codexHomeTarget = join(root, "codex-home-target");
const codexHomeLink = join(root, "codex-home-link");
const worktree = join(codexHomeTarget, "worktrees", "9aad", "first-tree");
const nested = join(worktree, "apps", "cli");
mkdirSync(nested, { recursive: true });
symlinkSync(codexHomeTarget, codexHomeLink);

withSignedIn(() => {
const resolution = inspectContextSetupLocation("codex", {
cwd: join(codexHomeLink, "worktrees", "9aad", "first-tree", "apps", "cli"),
env: { CODEX_HOME: codexHomeLink },
});
expect(resolution).toMatchObject({
project: { kind: "path", root: nested },
directory: nested,
directoryAvailable: false,
temporaryDirectory: true,
});
});
});

it("keeps directory setup available for an ordinary Codex project", () => {
const root = mkdtempSync(join(tmpdir(), "codex-ordinary-setup-"));
withSignedIn(() => {
expect(
inspectContextSetupLocation("codex", {
cwd: root,
env: { CODEX_HOME: join(root, ".different-codex-home") },
}),
).toMatchObject({
project: { kind: "path", root },
directory: root,
directoryAvailable: true,
temporaryDirectory: false,
});
});
});

it("caches the first Codex classification by session id across cwd changes", () => {
const pluginData = mkdtempSync(join(tmpdir(), "codex-project-cache-"));
const first = mkdtempSync(join(tmpdir(), "codex-project-first-"));
Expand Down Expand Up @@ -259,3 +345,20 @@ describe("Context project resolver", () => {
}
});
});

function withSignedIn<T>(run: () => T): T {
const root = mkdtempSync(join(tmpdir(), "context-project-signed-in-"));
const previousHome = process.env.FIRST_TREE_HOME;
mkdirSync(join(root, "config"));
writeFileSync(
join(root, "config", "credentials.json"),
JSON.stringify({ accessToken: "access", refreshToken: "refresh", serverUrl: "https://first-tree.test" }),
);
process.env.FIRST_TREE_HOME = root;
try {
return run();
} finally {
if (previousHome === undefined) delete process.env.FIRST_TREE_HOME;
else process.env.FIRST_TREE_HOME = previousHome;
}
}
21 changes: 13 additions & 8 deletions apps/cli/src/commands/context/enable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ function configure(command: Command): void {
command
.requiredOption("--provider <provider>", "claude-code or codex")
.requiredOption("--team <team-id>", "Team from the server-authored Setup handoff")
.option("--plan", "inspect the provider location and return the three activation choices without mutating state")
.option(
"--plan",
"inspect the provider location and return the available activation choices without mutating state",
)
.option("--scope <scope>", "apply global, directory, or session activation")
.option("--plan-id <plan-id>", "exact plan id returned by --plan")
.option("--project-root <directory>", "explicit provider directory")
Expand Down Expand Up @@ -191,6 +194,7 @@ async function buildSetupPlan(
accountClientId,
project: location.project,
directory: location.directory,
directoryAvailable: location.directoryAvailable,
temporaryDirectory: location.temporaryDirectory,
};
const grantStore = inspectContextGrantStore();
Expand All @@ -199,13 +203,14 @@ async function buildSetupPlan(
organizationId: activation.team.organizationId,
activationScope: { kind: "global" },
};
const directoryGrant: ContextIntegrationGrant | null = location.directory
? {
provider,
organizationId: activation.team.organizationId,
activationScope: { kind: "directory", root: location.directory },
}
: null;
const directoryGrant: ContextIntegrationGrant | null =
location.directoryAvailable && location.directory
? {
provider,
organizationId: activation.team.organizationId,
activationScope: { kind: "directory", root: location.directory },
}
: null;
const tokenWithoutChallenge = {
identityFingerprint: createHash("sha256").update(JSON.stringify(planIdentity)).digest("hex"),
beforeFingerprint: grantStore.fingerprint,
Expand Down
Loading
Loading