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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ dist/
.env.*
!.env.example
coverage/

# local agent/machine config (personal hostnames — never commit)
AGENTS.md
.serena/
15 changes: 14 additions & 1 deletion src/broker/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { TunnelProvider } from "../tunnel/provider.js";
import { Logger, nullLogger } from "../logger/index.js";
import { DEFAULT_HOST, DEFAULT_PORT, getStateDir } from "../config/paths.js";
import { SERVICE_NAME, VERSION } from "../version.js";
import { writeRuntimeState, clearRuntimeState, type RuntimeState } from "../bridge/runtime.js";
import { writeRuntimeState, clearRuntimeState, probeBridge, type RuntimeState } from "../bridge/runtime.js";
import { createAdminGuard } from "../bridge/admin-guard.js";
import {
loadOrCreateInstallation,
Expand Down Expand Up @@ -333,6 +333,19 @@ export async function startBroker(opts: BrokerOptions = {}): Promise<Broker> {
});

const { server, port } = await listen(app, host, opts.port ?? DEFAULT_PORT);
// Duplicate-daemon guard: if the preferred port was taken and we fell back
// to an ephemeral one, refuse to shadow an already-running broker for the
// same installation (it would split the CLI from the tunnel-bearing broker).
const preferredPort = opts.port ?? DEFAULT_PORT;
if (port !== preferredPort) {
const occupant = await probeBridge(preferredPort);
if (occupant && occupant.workspaceId === installation.installationId) {
server.close();
throw new Error(
`A broker for this installation is already running on port ${preferredPort}; not starting a duplicate.`
);
}
}
const startedAt = new Date().toISOString();
logger.info(
`Broker listening on ${host}:${port} for installation ${installation.installationId} ` +
Expand Down
5 changes: 5 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ program
.name("c2c")
.description(`${PRODUCT_NAME} — Claude thinks. Codex works.`)
.version(VERSION, "-v, --version")
.option("--profile <name>", "operate an isolated installation (~/.c2c/profiles/<name>)")
.hook("preAction", () => {
const profile = (program.opts().profile as string | undefined)?.trim();
if (profile) process.env.C2C_PROFILE = profile;
})
.configureHelp({ sortSubcommands: true });

// ---------------------------------------------------------------- serve (internal)
Expand Down
4 changes: 4 additions & 0 deletions src/config/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export function getStateDir(): string {
const override = process.env.C2C_STATE_DIR;
if (override && override.trim() !== "") return path.resolve(override);

// 1b. named profile: an isolated installation (~/.c2c/profiles/<name>)
const profile = process.env.C2C_PROFILE?.trim();
if (profile) return path.join(getC2cHome(), "profiles", profile);

// 2. the systemwide installation home, once `c2c install` has created it
const homeState = path.join(getC2cHome(), "state");
if (fs.existsSync(homeState)) return homeState;
Expand Down
6 changes: 5 additions & 1 deletion src/tunnel/installation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,15 @@ export async function resolveInstallationTunnel(
message: "Tell me your Cloudflare domain, e.g. example.com",
});
}
// Named profiles need their own Cloudflare tunnel; reusing the default
// name would route both profiles' hostnames at one installation.
const profile = process.env.C2C_PROFILE?.trim();
const result = await provisionNamedTunnel({
workspaceId: INSTALLATION_TUNNEL_ID,
workspaceName: "installation",
workspaceName: profile ? `installation-${profile}` : "installation",
zone,
hostname: opts.hostname,
tunnelName: profile ? `c2c-installation-${profile}` : undefined,
});
state = result.state;
const { url } = await restartBrokerTunnel();
Expand Down
3 changes: 2 additions & 1 deletion src/tunnel/named-provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export async function provisionNamedTunnel(opts: {
workspaceName: string;
zone: string;
hostname?: string;
tunnelName?: string;
account?: CloudflaredAccount;
}): Promise<ProvisionNamedResult> {
const account = opts.account ?? new ProcessCloudflaredAccount();
Expand All @@ -200,7 +201,7 @@ export async function provisionNamedTunnel(opts: {
return fallbackState(opts.workspaceId, "invalid_hostname", (error as Error).message);
}

const tunnelName = `c2c-${opts.workspaceId}`;
const tunnelName = opts.tunnelName ?? `c2c-${opts.workspaceId}`;
try {
if (!account.hasCert()) await account.login();
const tunnel = await account.createTunnel(tunnelName);
Expand Down
26 changes: 26 additions & 0 deletions tests/workspaces-domain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,3 +263,29 @@ describe("state dir precedence", () => {
}
});
});

describe("profile state resolution", () => {
it("routes state to ~/.c2c/profiles/<name> via C2C_PROFILE", async () => {
const { getStateDir, getC2cHome } = await import("../src/config/paths.js");
delete process.env.C2C_STATE_DIR;
process.env.C2C_PROFILE = "wiriawan-gmail";
try {
expect(getStateDir()).toBe(path.join(getC2cHome(), "profiles", "wiriawan-gmail"));
} finally {
delete process.env.C2C_PROFILE;
}
});

it("keeps C2C_STATE_DIR stronger than C2C_PROFILE", async () => {
const { getStateDir } = await import("../src/config/paths.js");
const scratch = makeTmpDir("profile-scratch");
process.env.C2C_STATE_DIR = scratch;
process.env.C2C_PROFILE = "wiriawan-gmail";
try {
expect(getStateDir()).toBe(scratch);
} finally {
delete process.env.C2C_STATE_DIR;
delete process.env.C2C_PROFILE;
}
});
});
Loading