diff --git a/.gitignore b/.gitignore index 9989634..ea391f5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ dist/ .env.* !.env.example coverage/ + +# local agent/machine config (personal hostnames — never commit) +AGENTS.md +.serena/ diff --git a/src/broker/server.ts b/src/broker/server.ts index 775d039..a837337 100644 --- a/src/broker/server.ts +++ b/src/broker/server.ts @@ -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, @@ -333,6 +333,19 @@ export async function startBroker(opts: BrokerOptions = {}): Promise { }); 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} ` + diff --git a/src/cli/index.ts b/src/cli/index.ts index c1fd476..940cbb2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -161,6 +161,11 @@ program .name("c2c") .description(`${PRODUCT_NAME} — Claude thinks. Codex works.`) .version(VERSION, "-v, --version") + .option("--profile ", "operate an isolated installation (~/.c2c/profiles/)") + .hook("preAction", () => { + const profile = (program.opts().profile as string | undefined)?.trim(); + if (profile) process.env.C2C_PROFILE = profile; + }) .configureHelp({ sortSubcommands: true }); // ---------------------------------------------------------------- serve (internal) diff --git a/src/config/paths.ts b/src/config/paths.ts index 3642a29..2c936c4 100644 --- a/src/config/paths.ts +++ b/src/config/paths.ts @@ -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/) + 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; diff --git a/src/tunnel/installation.ts b/src/tunnel/installation.ts index ad158e6..e04e555 100644 --- a/src/tunnel/installation.ts +++ b/src/tunnel/installation.ts @@ -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(); diff --git a/src/tunnel/named-provision.ts b/src/tunnel/named-provision.ts index 235cfaa..6264ec4 100644 --- a/src/tunnel/named-provision.ts +++ b/src/tunnel/named-provision.ts @@ -188,6 +188,7 @@ export async function provisionNamedTunnel(opts: { workspaceName: string; zone: string; hostname?: string; + tunnelName?: string; account?: CloudflaredAccount; }): Promise { const account = opts.account ?? new ProcessCloudflaredAccount(); @@ -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); diff --git a/tests/workspaces-domain.test.ts b/tests/workspaces-domain.test.ts index 6c70a94..029e4c9 100644 --- a/tests/workspaces-domain.test.ts +++ b/tests/workspaces-domain.test.ts @@ -263,3 +263,29 @@ describe("state dir precedence", () => { } }); }); + +describe("profile state resolution", () => { + it("routes state to ~/.c2c/profiles/ 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; + } + }); +});