diff --git a/apps/daemon/AGENTS.md b/apps/daemon/AGENTS.md index 0fdb34629..479422022 100644 --- a/apps/daemon/AGENTS.md +++ b/apps/daemon/AGENTS.md @@ -6,10 +6,18 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr ## State surfaces — all under the profile's state dir -- Default state dir is `~/.linkcode/`; `LINKCODE_PROFILE=` (`[a-z0-9-]`, ≤32 chars, invalid - aborts boot) forks the whole universe to the sibling `~/.linkcode-/` — including `hq.json` / - `device-key.pem`, so each profile registers as its own HQ device (deliberate: the relay allows one - uplink per device id). `~/LinkCode` workspaces and the managed asset store stay shared. +- The state dir is picked by **channel × profile** (CODE-460). Channel comes from + `daemonChannel()` (`src/paths.ts`): injected `LINKCODE_CHANNEL` → tsup's build-time + `LINKCODE_BUILD_CHANNEL` stamp (`release`) → `development`. So `~/.linkcode/` is the released + app's alone and running the source lands in `~/.linkcode.development/`; the suffix is + dot-separated because profile names forbid dots, making collision with `--profile=development` + impossible. `LINKCODE_PROFILE=` (`[a-z0-9-]`, ≤32 chars, invalid aborts boot) then forks + the sibling `~/.linkcode[.development]-/` — including `hq.json` / `device-key.pem`, so each + universe registers as its own HQ device (deliberate: the relay allows one uplink per device id). + Workspaces (`~/LinkCode` vs `~/LinkCode Development`) and the managed asset store fork by channel + but are shared across a channel's profiles. **Resolve the channel per call, never at module + load** — `instrument.ts` derives a state path in its module body, and `--import` runs it before + `index.ts` (the CODE-166 bug class). - **Paths are owned by `src/config.ts`** (`configPath` / `databasePath` / `runtimeFilePath`) — never scatter `homedir()` joins elsewhere. `os.homedir()` is read at call time, so a fake `$HOME` fully redirects config/db/runtime (this is what isolates an E2E daemon). @@ -29,13 +37,14 @@ Runs via `tsx` in dev (`pnpm -F @linkcode/daemon dev`) and a `tsup` bundle in pr in `packages/foundation/schema`). `LINKCODE_PORT` / `LINKCODE_HOST` override every listener. On `EADDRINUSE` a listener hunts **upward** up to 10 ports (19523–19532); clients must read `runtime.json`, never assume 19523. -- **One daemon per profile**, enforced by the daemon (not the desktop supervisor): `main()` calls +- **One daemon per universe (channel × profile)**, enforced by the daemon (not the desktop supervisor): `main()` calls `findRunningDaemon()` — parse the profile's `runtime.json` → pid alive? → `GET /linkcode` identity pid matches? A live one makes the new process log `already running (pid N)` and `process.exit(3)` (`DAEMON_EXIT_ALREADY_RUNNING`) — an **explicit** exit because Electron's `utilityProcess` keeps the - parent IPC channel and the event loop alive forever. The identity (and `runtime.json`) carries an - optional `profile` field (absent = default profile): the port hunt treats a live daemon of - **another** profile as a port neighbor and hunts past it, so profiles coexist on adjacent ports. + parent IPC channel and the event loop alive forever. The identity (and `runtime.json`) carries + optional `profile` and `channel` fields (absent = default profile / release, which is what every + pre-split daemon is): the port hunt treats a live daemon of **another** universe as a port + neighbor and hunts past it, so a dev daemon starts fine on 19524 while a release holds 19523. Health: `curl http://127.0.0.1:19523/linkcode`. ## Packaging: spawn, binaries & bundle diff --git a/apps/daemon/e2e/startup.e2e.ts b/apps/daemon/e2e/startup.e2e.ts index aec919472..a843129d0 100644 --- a/apps/daemon/e2e/startup.e2e.ts +++ b/apps/daemon/e2e/startup.e2e.ts @@ -66,6 +66,8 @@ async function main(): Promise { let client: LinkCodeClient | null = null; try { + // Plain `.linkcode`, not the development sibling: this drives the tsup bundle, which is + // stamped `release` at build time (CODE-460). Running the TS source would land elsewhere. const runtimePath = join(home, '.linkcode', 'runtime.json'); const runtime = await waitFor( () => { diff --git a/apps/daemon/scripts/dev-clean.mts b/apps/daemon/scripts/dev-clean.mts index 99c90e23f..ae90d3fe3 100644 --- a/apps/daemon/scripts/dev-clean.mts +++ b/apps/daemon/scripts/dev-clean.mts @@ -1,6 +1,6 @@ import { rmSync } from 'node:fs'; import { databasePath, runtimeFilePath } from '../src/config'; -// Resolves through config.ts so a fork's renamed STATE_DIR_BASENAME or an active +// Resolves through config.ts so a fork's renamed state dir, the resolved channel, or an active // LINKCODE_PROFILE cleans the same universe the dev daemon will actually use. for (const path of [databasePath(), runtimeFilePath()]) rmSync(path, { force: true }); diff --git a/apps/daemon/src/__tests__/config.test.ts b/apps/daemon/src/__tests__/config.test.ts index 363d2b93f..7a5cb1a0f 100644 --- a/apps/daemon/src/__tests__/config.test.ts +++ b/apps/daemon/src/__tests__/config.test.ts @@ -11,19 +11,23 @@ import { runtimeFilePath, } from '../config'; import { logger } from '../logger'; -import { telemetryConfigCachePath } from '../paths'; +import { daemonChannel, telemetryConfigCachePath } from '../paths'; let savedHome: string | undefined; -// loadConfig() reads ~/.linkcode/config.json; point HOME at a fresh temp dir per test. +// loadConfig() reads the channel's config.json; point HOME at a fresh temp dir per test. The +// channel is pinned to release so these cases keep asserting plain `~/.linkcode` — running the TS +// source would otherwise resolve as development. The channel axis itself is covered further down. beforeEach(() => { savedHome = process.env.HOME; process.env.HOME = mkdtempSync(join(tmpdir(), 'linkcode-config-')); + process.env.LINKCODE_CHANNEL = 'release'; }); afterEach(() => { process.env.HOME = savedHome; delete process.env.LINKCODE_PROFILE; + delete process.env.LINKCODE_CHANNEL; vi.restoreAllMocks(); }); @@ -134,6 +138,58 @@ describe('profile-scoped state paths', () => { }); }); +// CODE-460: a local build must never land in the installed release's universe — that shared +// `~/.linkcode` is what let a dev daemon hold 19523 and serve a release client frames it drops. +describe('channel-scoped state paths', () => { + it('forks the development channel into its own state directory', () => { + process.env.LINKCODE_CHANNEL = 'development'; + const root = join(process.env.HOME ?? '', '.linkcode.development'); + expect(daemonChannel()).toBe('development'); + expect(databasePath()).toBe(join(root, 'daemon.db')); + expect(runtimeFilePath()).toBe(join(root, 'runtime.json')); + expect(hqCredentialsPath()).toBe(join(root, 'hq.json')); + expect(telemetryConfigCachePath()).toBe(join(root, 'telemetry-config.json')); + }); + + it('composes the channel with a profile, dot before hyphen', () => { + process.env.LINKCODE_CHANNEL = 'development'; + process.env.LINKCODE_PROFILE = 'alpha'; + expect(databasePath()).toBe( + join(process.env.HOME ?? '', '.linkcode.development-alpha', 'daemon.db'), + ); + }); + + // The dot separator is what makes this impossible to express as a profile name, so a release + // build can never be talked into the development directory by `--profile=development`. + it('keeps a profile named after the channel out of the development directory', () => { + process.env.LINKCODE_PROFILE = 'development'; + expect(databasePath()).toBe(join(process.env.HOME ?? '', '.linkcode-development', 'daemon.db')); + }); + + it('defaults to development when nothing is injected — an unstamped build is a working copy', () => { + delete process.env.LINKCODE_CHANNEL; + expect(daemonChannel()).toBe('development'); + expect(databasePath()).toBe(join(process.env.HOME ?? '', '.linkcode.development', 'daemon.db')); + }); + + it('aborts on an invalid channel instead of silently picking a universe', () => { + process.env.LINKCODE_CHANNEL = 'prod'; + expect(() => daemonChannel()).toThrow(TypeError); + expect(() => databasePath()).toThrow(TypeError); + }); + + it('lets an injected channel outrank the build stamp', () => { + // The devshell pack ships a bundle stamped `release` inside a development shell. + process.env.LINKCODE_BUILD_CHANNEL = 'release'; + process.env.LINKCODE_CHANNEL = 'development'; + try { + expect(daemonChannel()).toBe('development'); + } finally { + delete process.env.LINKCODE_BUILD_CHANNEL; + } + }); +}); + describe('loadConfig accounts', () => { it('keeps valid accounts and drops an invalid one, logging the error', () => { const errorSpy = vi.spyOn(logger, 'warn').mockImplementation(noop); diff --git a/apps/daemon/src/config.ts b/apps/daemon/src/config.ts index c06d08741..8a6d72ac5 100644 --- a/apps/daemon/src/config.ts +++ b/apps/daemon/src/config.ts @@ -10,12 +10,12 @@ import { ProviderConfigSchema, SimulatorConsentStateSchema, } from '@linkcode/schema'; -import { WORKSPACES_DIRNAME } from '@linkcode/schema/product'; +import { workspacesDirName } from '@linkcode/schema/product'; import type { TransportServerOptions } from '@linkcode/transport/server'; import { logger } from './logger'; -import { daemonProfile, daemonStateDir } from './paths'; +import { daemonChannel, daemonProfile, daemonStateDir } from './paths'; -export { daemonProfile } from './paths'; +export { daemonChannel, daemonProfile } from './paths'; /** * Daemon configuration: `config.json` in the profile's state dir (optional) with env overrides. @@ -57,7 +57,7 @@ export function databasePath(): string { /** Runtime discovery file advertising the running daemon's bound endpoints, next to config.json. */ export function runtimeFilePath(): string { - return daemonRuntimeFilePath(daemonProfile()); + return daemonRuntimeFilePath(daemonChannel(), daemonProfile()); } /** HQ sign-in state (session token + registered device id), next to config.json; written 0600. */ @@ -81,7 +81,7 @@ export function deviceKeysDir(): string { * independently — a system-plane invariant enforced regardless of which client is connected. */ export function chatWorkspaceRoot(): string { - return join(homedir(), WORKSPACES_DIRNAME); + return join(homedir(), workspacesDirName(daemonChannel())); } export function loadConfig(): DaemonConfig { diff --git a/apps/daemon/src/index.ts b/apps/daemon/src/index.ts index f963e8d07..fe9139ad9 100644 --- a/apps/daemon/src/index.ts +++ b/apps/daemon/src/index.ts @@ -30,6 +30,7 @@ import { installAsarSpawnFix } from './asar-spawn'; import type { DaemonConfig } from './config'; import { chatWorkspaceRoot, + daemonChannel, daemonProfile, databasePath, loadConfig, @@ -135,8 +136,10 @@ const teardown: Runtime.Teardown = (exit, onExit) => { */ async function main(): Promise { // Resolved before anything (subcommands included) touches state paths: an invalid - // LINKCODE_PROFILE must abort here, not mid-command or as a default-profile daemon. + // LINKCODE_PROFILE or LINKCODE_CHANNEL must abort here, not mid-command or as a daemon that + // silently landed in the default universe. const profile = daemonProfile(); + const channel = daemonChannel(); // Subcommands run and exit instead of booting the host (a running daemon // picks the new sign-in state up on its next restart). @@ -152,8 +155,9 @@ async function main(): Promise { Shared, Effect.gen(function* () { const config = loadConfig(); - // One daemon per profile — a second instance would share this profile's daemon.db and split - // sessions. Daemons of other profiles live in sibling state dirs and are not visible here. + // One daemon per universe (channel × profile) — a second instance would share this + // universe's daemon.db and split sessions. Daemons of other channels/profiles live in + // sibling state dirs and are not visible here. const running = yield* Effect.promise(findRunningDaemon); if (running) { const urls = running.listeners.map((listener) => listener.url).join(', '); @@ -168,6 +172,9 @@ async function main(): Promise { pid: process.pid, startedAt: Date.now(), ...(profile !== undefined && { profile }), + // Absent means release on the wire, so only development needs stating — this keeps a + // release daemon's identity and runtime.json byte-identical to pre-split ones. + ...(channel !== 'release' && { channel }), }; const hub = new Hub(); yield* Effect.addFinalizer(() => diff --git a/apps/daemon/src/paths.ts b/apps/daemon/src/paths.ts index 793919ed9..1576ef5e4 100644 --- a/apps/daemon/src/paths.ts +++ b/apps/daemon/src/paths.ts @@ -1,15 +1,32 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; +import type { ProductChannel } from '@linkcode/schema'; import { linkcodeStateDirName, parseProfileName } from '@linkcode/schema'; +import { resolveProductChannel } from '@linkcode/schema/product'; /** Profile from `LINKCODE_PROFILE`; invalid names fail boot instead of crossing state universes. */ export function daemonProfile(): string | undefined { return parseProfileName(process.env.LINKCODE_PROFILE); } -/** The daemon's profile-aware state directory. Safe to import before logging/Sentry initialization. */ +/** + * This build's channel, which picks the whole on-disk universe (CODE-460). `LINKCODE_CHANNEL` — + * injected by the desktop supervisor — outranks the build-time marker, because the devshell pack + * ships a tsup bundle stamped `release` inside a `development` shell. tsup replaces + * `process.env.LINKCODE_BUILD_CHANNEL` with a literal (see its `define`); running the TS source + * leaves it undefined, so a dev daemon defaults to `development` with nothing to remember. + * + * Resolved on every call, never cached at module load: `instrument.ts` derives a state path in its + * module body, and `--import` runs it before `index.ts` — anything hung off entry-point side + * effects would capture the wrong universe (the CODE-166 bug class). + */ +export function daemonChannel(): ProductChannel { + return resolveProductChannel(process.env.LINKCODE_CHANNEL, process.env.LINKCODE_BUILD_CHANNEL); +} + +/** The daemon's channel × profile state directory. Safe to import before logging/Sentry initialization. */ export function daemonStateDir(): string { - return join(homedir(), linkcodeStateDirName(daemonProfile())); + return join(homedir(), linkcodeStateDirName(daemonChannel(), daemonProfile())); } export function telemetryConfigCachePath(): string { diff --git a/apps/daemon/src/runtime.ts b/apps/daemon/src/runtime.ts index a1b7e650a..3d4aef121 100644 --- a/apps/daemon/src/runtime.ts +++ b/apps/daemon/src/runtime.ts @@ -90,9 +90,10 @@ async function huntFrom( if (!isAddrInUse(err)) throw err; const probeUrl = httpUrl(listener.host, port); const occupant = await probeDaemonIdentity(probeUrl); - // Our own pid = another of this daemon's listeners hunted onto the port; another profile - // (absent field = default) is an isolated universe, not a double-start — hunt past both. - if (occupant && occupant.pid !== identity.pid && occupant.profile === identity.profile) { + // Our own pid = another of this daemon's listeners hunted onto the port; another universe + // (channel × profile, absent fields = release × default) is isolated, not a double-start — + // hunt past both. This is what lets a dev daemon start while a release one holds 19523. + if (occupant && occupant.pid !== identity.pid && sameUniverse(occupant, identity)) { throw new DaemonAlreadyRunningError(occupant, probeUrl); } if (attempt + 1 >= PORT_HUNT_ATTEMPTS) { @@ -105,6 +106,12 @@ async function huntFrom( } } +/** Same state universe = same `daemon.db`. Both fields are optional on the wire and default to the + * pre-split universe, so a daemon predating either axis compares equal to release × default. */ +function sameUniverse(a: DaemonIdentity, b: DaemonIdentity): boolean { + return (a.channel ?? 'release') === (b.channel ?? 'release') && a.profile === b.profile; +} + /** * The daemon advertised by the runtime file, or `null`: file missing/malformed (stale leftovers * are overwritten on next start), pid dead, or the endpoint not answering as a linkcode daemon. diff --git a/apps/daemon/tests/integration/runtime.test.ts b/apps/daemon/tests/integration/runtime.test.ts index 2fcaa047b..371dd8c72 100644 --- a/apps/daemon/tests/integration/runtime.test.ts +++ b/apps/daemon/tests/integration/runtime.test.ts @@ -163,6 +163,40 @@ describe('listenWithPortHunt', () => { ), ).rejects.toBeInstanceOf(DaemonAlreadyRunningError); }); + + // CODE-460: this is what lets a dev daemon start while an installed release holds 19523. + // Both default profiles are `undefined`, so without the channel comparison the dev daemon + // reads the release one as a double-start and exits 3. + it('hunts past a live daemon of another channel', async () => { + const port = await serveIdentity(identity(4242)); + const { server, url } = await listenWithPortHunt( + { type: 'ws', port, host: '127.0.0.1' }, + { ...identity(process.pid), channel: 'development' }, + ); + expect(url).toBe(`ws://127.0.0.1:${port + 1}`); + await server.close(); + }); + + it('refuses to hunt past a live daemon of the same channel', async () => { + const port = await serveIdentity({ ...identity(4242), channel: 'development' }); + await expect( + listenWithPortHunt( + { type: 'ws', port, host: '127.0.0.1' }, + { ...identity(process.pid), channel: 'development' }, + ), + ).rejects.toBeInstanceOf(DaemonAlreadyRunningError); + }); + + // An absent channel is release's, so a pre-CODE-460 daemon still reads as a double-start. + it('treats an occupant with no channel field as release', async () => { + const port = await serveIdentity(identity(4242)); + await expect( + listenWithPortHunt( + { type: 'ws', port, host: '127.0.0.1' }, + { ...identity(process.pid), channel: 'release' }, + ), + ).rejects.toBeInstanceOf(DaemonAlreadyRunningError); + }); }); describe('findRunningDaemon', () => { diff --git a/apps/daemon/tsup.config.ts b/apps/daemon/tsup.config.ts index 958365d81..b37736abd 100644 --- a/apps/daemon/tsup.config.ts +++ b/apps/daemon/tsup.config.ts @@ -10,6 +10,11 @@ export default defineConfig({ // Desktop packaging copies only index.js into the asar (electron.vite.config.ts // bundle-daemon-artifact); a split bundle boots to ERR_MODULE_NOT_FOUND on the missing chunk-*.js. splitting: false, + // Stamps the channel into the bundle (CODE-460): a built daemon is a release artifact, while + // running the TS source leaves this undefined so `daemonChannel()` defaults to development. The + // desktop supervisor's LINKCODE_CHANNEL still outranks it — the devshell pack ships this very + // bundle inside a development shell. + define: { 'process.env.LINKCODE_BUILD_CHANNEL': JSON.stringify('release') }, // Inlined CJS deps call `require()`; esbuild's ESM output has none, so provide one or boot dies // with "Dynamic require of ... is not supported". The private alias avoids redeclaring a bundled // module's own preserved `import { createRequire }` (the banner is prepended after esbuild). diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index a5396e3ff..9f344e71a 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -50,4 +50,4 @@ Both fail only in packaged builds, produce no error, and are invisible to typech ## Identity isolation (channel × profile) -- **A local/unpackaged build must be isolated from an installed release on four axes** — app name, `userData` dir, single-instance lock, and OS keychain (safeStorage). The identity is `CHANNEL` (`release`/`development` → `LinkCode` vs `LinkCode Development`) × optional `PROFILE` (`--profile`/`LINKCODE_PROFILE`, suffixing ` ()`), both in `src/main/constants.ts`; a production bundle run by the dev Electron binary is still the `development` channel. `src/main/identity.ts` applies the identity and MUST stay the first import of `index.ts` — any module body that runs before it captures paths in the wrong universe (the CODE-166 bug class). Skipping any axis clobbers release settings, steals its lock, or poisons its keychain entry; channels and the default profile share `~/.linkcode` and `~/LinkCode` on purpose, and the supervisor injects the resolved `LINKCODE_PROFILE` into the daemon it spawns. Full consequences, procedure, and the keychain cleanup command → [`docs/DEVELOPMENT.md`](../../docs/DEVELOPMENT.md). +- **A local/unpackaged build must be isolated from an installed release on four axes** — app name, `userData` dir, single-instance lock, and OS keychain (safeStorage). The identity is `CHANNEL` (`release`/`development` → `LinkCode` vs `LinkCode Development`) × optional `PROFILE` (`--profile`/`LINKCODE_PROFILE`, suffixing ` ()`), both in `src/main/constants.ts`; a production bundle run by the dev Electron binary is still the `development` channel. `src/main/identity.ts` applies the identity and MUST stay the first import of `index.ts` — any module body that runs before it captures paths in the wrong universe (the CODE-166 bug class). Skipping any axis clobbers release settings, steals its lock, or poisons its keychain entry. Since CODE-460 the daemon-side state (`~/.linkcode` vs `~/.linkcode.development`), the workspace root, and the managed asset store fork by channel too, so the supervisor injects **both** `LINKCODE_PROFILE` and `LINKCODE_CHANNEL` into the daemon it spawns — the channel injection is load-bearing for the devshell pack, whose bundled daemon is stamped `release` at build time. Discovery must read `runtime.json` through the same pair (`daemonRuntimeFilePath(CHANNEL, PROFILE)`) or the shell dials the other channel's daemon. Full consequences, the per-channel path table, and the keychain cleanup command → [`docs/DEVELOPMENT.md`](../../docs/DEVELOPMENT.md). diff --git a/apps/desktop/e2e/file-tree.e2e.mts b/apps/desktop/e2e/file-tree.e2e.mts index 569b1df0d..2a9a86b24 100644 --- a/apps/desktop/e2e/file-tree.e2e.mts +++ b/apps/desktop/e2e/file-tree.e2e.mts @@ -241,7 +241,14 @@ async function main(): Promise { try { daemon = spawn(process.execPath, ['dist/index.js'], { cwd: daemonDir, - env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT) }, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_CHANNEL: 'development', + }, stdio: 'ignore', }); await waitForDaemon(); diff --git a/apps/desktop/e2e/notifications.e2e.mts b/apps/desktop/e2e/notifications.e2e.mts index 892f388a7..958541bc6 100644 --- a/apps/desktop/e2e/notifications.e2e.mts +++ b/apps/desktop/e2e/notifications.e2e.mts @@ -131,7 +131,14 @@ async function main(): Promise { try { daemon = spawn(process.execPath, ['dist/index.js'], { cwd: daemonDir, - env: { ...process.env, HOME: home, LINKCODE_PORT: String(PORT) }, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + env: { + ...process.env, + HOME: home, + LINKCODE_PORT: String(PORT), + LINKCODE_CHANNEL: 'development', + }, stdio: 'ignore', }); await waitForDaemon(); diff --git a/apps/desktop/e2e/packaged-smoke.e2e.mts b/apps/desktop/e2e/packaged-smoke.e2e.mts index fee66b8dc..3807cc92f 100644 --- a/apps/desktop/e2e/packaged-smoke.e2e.mts +++ b/apps/desktop/e2e/packaged-smoke.e2e.mts @@ -43,7 +43,10 @@ async function main(): Promise { const home = join(root, 'home'); const config = join(root, 'config'); const profile = `packaged-smoke-${process.pid}`; - const stateDir = join(home, `.linkcode-${profile}`); + // The devshell pack is the development channel, and its supervisor injects that into the daemon + // it spawns — so the state dir is the development sibling, profile-suffixed (CODE-460). Asserting + // the plain `.linkcode-` here would prove the channel injection had been lost. + const stateDir = join(home, `.linkcode.development-${profile}`); const runtimePath = join(stateDir, 'runtime.json'); mkdirSync(home); mkdirSync(config); diff --git a/apps/desktop/e2e/simulator-live.mts b/apps/desktop/e2e/simulator-live.mts index 1bfac3599..e7a61a59b 100644 --- a/apps/desktop/e2e/simulator-live.mts +++ b/apps/desktop/e2e/simulator-live.mts @@ -113,6 +113,9 @@ async function main(): Promise { HOME: home, LINKCODE_PORT: String(PORT), LINKCODE_PROFILE: profile, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + LINKCODE_CHANNEL: 'development', LINKCODE_SIM_SIDECAR_PATH: simSidecar, }, stdio: 'ignore', diff --git a/apps/desktop/e2e/simulator-panel.e2e.mts b/apps/desktop/e2e/simulator-panel.e2e.mts index 12d5bd163..317515acc 100644 --- a/apps/desktop/e2e/simulator-panel.e2e.mts +++ b/apps/desktop/e2e/simulator-panel.e2e.mts @@ -695,6 +695,9 @@ async function main(): Promise { HOME: home, LINKCODE_PORT: String(PORT), LINKCODE_PROFILE: profile, + // The dist bundle is stamped `release`, the dev Electron shell resolves as `development` — + // without this they pick different state dirs and the shell never finds runtime.json. + LINKCODE_CHANNEL: 'development', LINKCODE_SIM_SIDECAR_PATH: simSidecar, }, stdio: 'ignore', diff --git a/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts b/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts index e9a291fc4..697cac433 100644 --- a/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts +++ b/apps/desktop/src/main/__tests__/daemon-supervisor.test.ts @@ -218,4 +218,18 @@ describe('daemon supervisor recovery', () => { const forkArgs = mocks.fork.mock.calls[0] as [string, string[], { execArgv?: string[] }]; expect(forkArgs[2].execArgv).toEqual([]); }); + + // The child cannot infer this: the devshell pack bundles a daemon stamped `release` at build + // time, so only the shell knows it is a development build (CODE-460). Without the injection + // that daemon would serve the installed release's state directory and port. + it("injects the shell's channel into the daemon it spawns", async () => { + await startSupervisor(); + + const forkArgs = mocks.fork.mock.calls[0] as [ + string, + string[], + { env?: Record }, + ]; + expect(forkArgs[2].env?.LINKCODE_CHANNEL).toBe('development'); + }); }); diff --git a/apps/desktop/src/main/constants.ts b/apps/desktop/src/main/constants.ts index db9a3d11c..746a7f7f8 100644 --- a/apps/desktop/src/main/constants.ts +++ b/apps/desktop/src/main/constants.ts @@ -1,4 +1,6 @@ +import type { ProductChannel } from '@linkcode/schema/daemon-runtime'; import { parseProfileName } from '@linkcode/schema/daemon-runtime'; +import { workspacesDirName } from '@linkcode/schema/product'; import { app, dialog } from 'electron'; import { extractErrorMessage } from 'foxts/extract-error-message'; @@ -13,7 +15,7 @@ import { extractErrorMessage } from 'foxts/extract-error-message'; * (`--profile=` / `LINKCODE_PROFILE`) forking the same surfaces again; passed on to the * supervised daemon, which forks its state dir and device identity (see `apps/daemon/src/config.ts`). */ -export type Channel = 'release' | 'development'; +export type Channel = ProductChannel; export const CHANNEL: Channel = import.meta.env.MODE !== 'production' || !app.isPackaged ? 'development' : 'release'; @@ -53,5 +55,7 @@ const BASE_ID = export const APP_ID = PROFILE === undefined ? BASE_ID : `${BASE_ID}.${PROFILE}`; -/** `~/LinkCode` holds user workspaces — shared across channels and profiles on purpose. */ -export { WORKSPACES_DIRNAME as DEFAULT_WORKSPACES_DIRNAME } from '@linkcode/schema/product'; +/** The channel's workspace directory (`~/LinkCode`, `~/LinkCode Development`) — shared across + * that channel's profiles on purpose, but never across channels (CODE-460). Must agree with the + * daemon's `chatWorkspaceRoot()`, which derives the same name from its own resolved channel. */ +export const DEFAULT_WORKSPACES_DIRNAME = workspacesDirName(CHANNEL); diff --git a/apps/desktop/src/main/daemon-discovery.ts b/apps/desktop/src/main/daemon-discovery.ts index b9f071c5e..6a9c13972 100644 --- a/apps/desktop/src/main/daemon-discovery.ts +++ b/apps/desktop/src/main/daemon-discovery.ts @@ -2,7 +2,7 @@ import { mkdirSync, watch } from 'node:fs'; import { basename, dirname } from 'node:path'; import { daemonRuntimeFilePath, isPidAlive, readJsonFileSync } from '@linkcode/common/node'; import { DAEMON_DEFAULT_URL, DaemonRuntimeInfoSchema } from '@linkcode/schema'; -import { PROFILE } from './constants'; +import { CHANNEL, PROFILE } from './constants'; import { getSettings } from './settings'; /** @@ -14,7 +14,7 @@ export function resolveDaemonUrl(): string { } function discoverRuntimeUrl(): string | null { - const file = daemonRuntimeFilePath(PROFILE); + const file = daemonRuntimeFilePath(CHANNEL, PROFILE); const parsed = DaemonRuntimeInfoSchema.safeParse(readJsonFileSync(file)); if (!parsed.success || !isPidAlive(parsed.data.pid)) return null; // The renderer connects over Socket.IO; ignore listeners it cannot dial. @@ -30,7 +30,7 @@ const RUNTIME_WATCH_DEBOUNCE_MS = 100; * the file is created/removed across daemon lifetimes, and a watcher on a deleted inode goes blind. */ export function watchDaemonRuntime(onChange: () => void): () => void { - const file = daemonRuntimeFilePath(PROFILE); + const file = daemonRuntimeFilePath(CHANNEL, PROFILE); // The daemon creates its state dir on first start; create it up front so watching never races that. mkdirSync(dirname(file), { recursive: true }); let debounce: NodeJS.Timeout | null = null; diff --git a/apps/desktop/src/main/daemon-supervisor.ts b/apps/desktop/src/main/daemon-supervisor.ts index a171edf9a..fc7389518 100644 --- a/apps/desktop/src/main/daemon-supervisor.ts +++ b/apps/desktop/src/main/daemon-supervisor.ts @@ -4,14 +4,14 @@ import { DAEMON_EXIT_ALREADY_RUNNING } from '@linkcode/schema'; import type { UtilityProcess } from 'electron'; import { app, utilityProcess } from 'electron'; import log from 'electron-log'; -import { PROFILE } from './constants'; +import { CHANNEL, PROFILE } from './constants'; import { watchDaemonRuntime } from './daemon-discovery'; import { getSettings } from './settings'; /** * Supervises the bundled daemon (out/daemon/index.mjs): forks it under Electron's Node via * `utilityProcess`, started on app-ready, SIGTERMed on quit; closing windows leaves it running. - * It only spawns — the one-daemon-per-profile contract lives in the daemon itself + * It only spawns — the one-daemon-per-universe contract lives in the daemon itself * (apps/daemon/src/runtime.ts): an external daemon makes the child exit * DAEMON_EXIT_ALREADY_RUNNING and the supervisor stands down; which daemon clients dial is * discovery's job (runtime.json). @@ -98,6 +98,9 @@ function spawnDaemon(): void { // inherited LINKCODE_PROFILE, and the default universe must not leak a stray env value through. if (PROFILE === undefined) delete env.LINKCODE_PROFILE; else env.LINKCODE_PROFILE = PROFILE; + // The channel cannot be inferred by the child: the devshell pack bundles a daemon stamped + // `release` at build time, and only this shell knows it is a development build (CODE-460). + env.LINKCODE_CHANNEL = CHANNEL; const sidecar = sidecarPath(); if (existsSync(sidecar)) env.LINKCODE_PTY_SIDECAR_PATH = sidecar; else log.warn(`[linkcode/desktop] pty sidecar missing at ${sidecar}; terminals unavailable`); diff --git a/apps/desktop/src/main/default-picker-directory.ts b/apps/desktop/src/main/default-picker-directory.ts index c8ac14567..24ea549bc 100644 --- a/apps/desktop/src/main/default-picker-directory.ts +++ b/apps/desktop/src/main/default-picker-directory.ts @@ -4,8 +4,9 @@ import { app } from 'electron'; import { DEFAULT_WORKSPACES_DIRNAME } from './constants'; /** - * Default directory the native file/folder picker opens into (a visible `~/LinkCode`, created on - * demand). Unrelated to `WorkspaceRecord` despite the naming overlap — purely a picker default path. + * Default directory the native file/folder picker opens into (this channel's visible workspace + * root, created on demand). Unrelated to `WorkspaceRecord` despite the naming overlap — purely a + * picker default path. */ export async function ensureDefaultPickerDirectory(): Promise { const dir = join(app.getPath('home'), DEFAULT_WORKSPACES_DIRNAME); diff --git a/devenv.nix b/devenv.nix index 9d2238b39..5600a957d 100644 --- a/devenv.nix +++ b/devenv.nix @@ -72,8 +72,11 @@ fi ''; - scripts.daemon.exec = "pnpm run --filter @linkcode/daemon build:rust && LINKCODE_PROFILE=dev pnpm run --filter @linkcode/daemon dev"; - scripts.desktop.exec = "LINKCODE_PROFILE=dev pnpm run --filter @linkcode/desktop dev"; + # No LINKCODE_PROFILE here: the development channel now owns its own state dir, workspaces, and + # asset store (CODE-460), so a local run can never contend with an installed release. Pass + # --profile / LINKCODE_PROFILE yourself only to fork a second universe within this channel. + scripts.daemon.exec = "pnpm run --filter @linkcode/daemon build:rust && pnpm run --filter @linkcode/daemon dev"; + scripts.desktop.exec = "pnpm run --filter @linkcode/desktop dev"; scripts.mobile.exec = "pnpm run --filter @linkcode/mobile ios"; - scripts.app.exec = "pnpm run --filter @linkcode/daemon build:rust && LINKCODE_PROFILE=dev pnpm --filter @linkcode/daemon --filter @linkcode/desktop --parallel dev"; + scripts.app.exec = "pnpm run --filter @linkcode/daemon build:rust && pnpm --filter @linkcode/daemon --filter @linkcode/desktop --parallel dev"; } diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index d3f9ed4b3..6619f7de5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -319,10 +319,12 @@ git apply --3way "$(ls -t .devenv/state/prek/patches/*.patch | head -1)" ## Identity: channel × profile isolation -The desktop identity is two orthogonal axes (`apps/desktop/src/main/constants.ts`), and app name, `userData` dir, single-instance lock, and OS keychain (safeStorage) all derive from them; `src/main/identity.ts` applies the identity as main's **first import**, and boot logs a `userData: ` line as self-evidence. +The identity is two orthogonal axes (`apps/desktop/src/main/constants.ts`), and the desktop's app name, `userData` dir, single-instance lock, and OS keychain (safeStorage) all derive from them; `src/main/identity.ts` applies the identity as main's **first import**, and boot logs a `userData: ` line as self-evidence. Since CODE-460 the **daemon's** state follows the same two axes, so a local build and an installed release share nothing at all. - **channel** — `CHANNEL === 'development'` for any build that is not the released app: `MODE !== 'production' || !app.isPackaged` (a production bundle run by the dev Electron binary is still a dev shell). `APP_NAME` is `'LinkCode Development'` for dev, `'LinkCode'` for release. Skipping any isolation axis clobbers release settings, steals its instance lock (the second instance exits 0 silently), or writes a safeStorage key under the dev binary's code signature — after which the release app prompts for the keychain password on first launch (macOS keychain ACLs pin the creator cdhash). -- **profile** — an optional isolated universe: `--profile=` (or `LINKCODE_PROFILE`; `[a-z0-9-]`, ≤32 chars, invalid aborts boot). It suffixes the app name (`LinkCode Development (alpha)`) — forking the same four axes again — and is injected as `LINKCODE_PROFILE` into the supervised daemon, which forks its state dir to `~/.linkcode-` and its HQ device identity with it. Two profiles therefore run side by side: daemons hunt past each other's ports, and each desktop follows its own `runtime.json`. The devenv `daemon`/`desktop`/`app` scripts set `LINKCODE_PROFILE=dev` on their dev commands, so the daemon and the desktop share the profile and agree on its `runtime.json`; for another profile, invoke `pnpm -F … dev` directly with the value you want. +- **profile** — an optional isolated universe *within* a channel: `--profile=` (or `LINKCODE_PROFILE`; `[a-z0-9-]`, ≤32 chars, invalid aborts boot). It suffixes the app name (`LinkCode Development (alpha)`) — forking the same four axes again — and is injected as `LINKCODE_PROFILE` into the supervised daemon, which forks its state dir and HQ device identity with it. Profiles run side by side: daemons hunt past each other's ports, and each desktop follows its own `runtime.json`. The devenv `daemon`/`desktop`/`app` scripts pass **no** profile — the development channel is already its own universe; pass one yourself only to fork a second universe within that channel. + +The daemon is a separate process and cannot see `app.isPackaged`, so it resolves its own channel (`apps/daemon/src/paths.ts`): the desktop supervisor's injected `LINKCODE_CHANNEL` wins, else the build-time stamp tsup bakes in (`process.env.LINKCODE_BUILD_CHANNEL` → `release`), else `development`. Running the TS source is therefore a development daemon with nothing to remember, while a packaged one is release — and the devshell pack, which ships a `release`-stamped bundle inside a development shell, is corrected by the injection. Resolution is per call, never cached at module load: `instrument.ts` derives a state path in its module body and `--import` runs it before `index.ts`. Clean a polluted machine (also after the `LinkCode Dev` → `LinkCode Development` rename, which orphaned the old dir and keychain entry by design — a rename migration would carry ciphertext the new keychain entry cannot decrypt): @@ -332,7 +334,21 @@ security delete-generic-password -s "LinkCode Dev Safe Storage" # pre-rename l rm -rf "$HOME/Library/Application Support/LinkCode Dev" # pre-rename leftover ``` -Every channel and the default profile deliberately **share** `~/.linkcode` (daemon) and `~/LinkCode` (workspaces); only an explicit `--profile` forks the daemon state, and `~/LinkCode` plus the managed asset store stay shared even then. This sharing is why the devenv dev scripts default to the `dev` profile: a dev daemon on the default profile would contend for `~/.linkcode`/`19523` with an installed release, and whichever binds first wins — the loser's client then dials a peer on a different `WIRE_PROTOCOL_VERSION`, every frame is silently dropped, and it surfaces as "Unable to connect to the daemon". `package:devshell` uses `electron-builder.devshell.yml`; release packaging is CI-only (the old `dist` script was removed). +### What each channel owns on disk + +Names come from `packages/foundation/schema/src/product.ts` — the one file a fork edits to rename its footprint. + +| | release | development | +| --- | --- | --- | +| daemon state (`config.json`, `daemon.db`, `runtime.json`, `hq.json`, `device-key.pem`) | `~/.linkcode` | `~/.linkcode.development` | +| workspaces + daemon chat root | `~/LinkCode` | `~/LinkCode Development` | +| managed asset store | `…/Application Support/LinkCode/assets` | `…/Application Support/LinkCode Development/assets` | + +A profile appends `-` to the **state** directory only (`~/.linkcode.development-alpha`); workspaces and the asset store fork by channel alone. The development suffix is dot-separated on purpose: profile names forbid dots, so `--profile=development` can never reach the development channel's directory. + +Two things stay shared across channels by design: the agent CLIs' own homes (`~/.claude`, `~/.codex` — separating them would force a second agent login and cut you off from the CLI you use in a terminal), and the `linkcode://` scheme's OS-global nature, which is why the dev shell claims `linkcode-dev://` instead (CODE-182). + +Before CODE-460 the daemon state and workspaces were shared, and a dev daemon on the default profile contended for `~/.linkcode`/`19523` with an installed release: whichever bound first won, the loser's client dialed a peer on a different `WIRE_PROTOCOL_VERSION`, every frame was silently dropped, and it surfaced only as "Unable to connect to the daemon". Both daemons now coexist — the port hunt reads the occupant's `channel` off `GET /linkcode` and treats another channel as a neighbor, so the second one lands on 19524. `package:devshell` uses `electron-builder.devshell.yml`; release packaging is CI-only (the old `dist` script was removed). ## Formatting and linting diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 9982c0be2..7f0f1ab54 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -15,7 +15,9 @@ Read by the daemon, desktop, webview, or mobile at run time. | Variable | Read at | Effect | | --- | --- | --- | -| `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe: forks the daemon state dir to `~/.linkcode-`, plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Desktop reads it too, where `--profile=` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the shared `~/.linkcode`. | +| `LINKCODE_CHANNEL` | `apps/daemon/src/paths.ts` | Picks the daemon's on-disk universe: `release` → `~/.linkcode` + `~/LinkCode` + `…/LinkCode/assets`; `development` → the `LinkCode Development` / `.linkcode.development` set (CODE-460). Outranks the build-time stamp, which is why the desktop supervisor always injects its own `CHANNEL` — the devshell pack's bundled daemon is stamped `release`. Any other value aborts boot. | +| `LINKCODE_BUILD_CHANNEL` | `apps/daemon/tsup.config.ts` (`define`) | **Build-time stamp, not a runtime knob.** tsup replaces the literal with `release`, so a built daemon defaults to release and the TS source defaults to development. Setting it by hand in a shell works but is not the supported override — use `LINKCODE_CHANNEL`. | +| `LINKCODE_PROFILE` | `apps/daemon/src/config.ts` | Isolated state universe *within a channel*: forks the state dir to the `-` sibling (`~/.linkcode.development-alpha`), plus DB, `runtime.json`, and HQ device identity. `[a-z0-9-]`, ≤32 chars; invalid aborts boot. Workspaces and the asset store do not fork by profile. Desktop reads it too, where `--profile=` outranks it, and re-injects the resolved value into the supervised daemon. Unset = the channel's default universe. | | `LINKCODE_PORT` | `apps/daemon/src/config.ts` | Overrides every configured listener's port. Must parse as an integer in `1..65535`, otherwise the config value stands. | | `LINKCODE_HOST` | `apps/daemon/src/config.ts` | Overrides every listener's bind host. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/src/pty/sidecar.ts` | Absolute path to the `linkcode-pty` binary; always wins. Dev falls back to `target/release/linkcode-pty`; a bundled `dist/` daemon has no fallback and disables terminals. The packaged desktop supervisor sets it to `/sidecar/`. | @@ -81,8 +83,8 @@ client configuration or new build. | `LINKCODE_REQUIRE_PTY_SIDECAR` | `apps/daemon/tests/integration/pty-sidecar.test.ts`, `terminal-flood.test.ts` | `1` turns a missing `linkcode-pty` binary from a silent skip into a hard failure. CI sets it; set it locally too when you mean to exercise the real wire protocol. | | `LINKCODE_PTY_SIDECAR_PATH` | `apps/daemon/e2e/startup.e2e.ts` | Points the spawned daemon at the compiled sidecar (CI uses `target/debug/linkcode-pty`). | | `LINKCODE_HOST`, `LINKCODE_PORT` | daemon/webview/desktop E2E harnesses | Pin the harness daemon to `127.0.0.1` on an ephemeral port. | -| `LINKCODE_PROFILE` | desktop E2E | Isolates a run's state universe. Must be identical on both sides — the desktop app and its daemon — or they follow different `runtime.json` files. | -| `HOME` | every E2E harness | Redirected to a fresh temp dir so runs never touch the real `~/.linkcode`. Use a *fresh* one per run. | +| `LINKCODE_PROFILE` | desktop E2E | Isolates a run's state universe. Must be identical on both sides — the desktop app and its daemon — or they follow different `runtime.json` files. The same applies to `LINKCODE_CHANNEL` when a harness sets it. | +| `HOME` | every E2E harness | Redirected to a fresh temp dir so runs never touch the real state dirs. Use a *fresh* one per run. | | `LINKCODE_E2E_KEEP_OPEN` | `apps/desktop/e2e/simulator-panel.e2e.mts` | `1` hands the app over at the pause and waits for you to close the window instead of running a timer. The two section-close checks after the pause are given up in exchange — the alternative is yanking the window away from whoever is driving it. | | `LINKCODE_E2E_HOLD_MS` | `apps/desktop/e2e/simulator-panel.e2e.mts` | Widens the pause that leaves the window live to be driven by hand (default `30000`). For demoing the simulator panel rather than checking it. | | `LINKCODE_E2E_SKIP_RECLAIM` | `apps/desktop/e2e/simulator-panel.e2e.mts` | `1` drops the closing CODE-419 reclaim check, whose last act is to SIGTERM the daemon — correct in a test, looks like a crash in a demo. The run then proves everything *except* reclaim-on-shutdown. | diff --git a/packages/foundation/common/src/node/index.ts b/packages/foundation/common/src/node/index.ts index fd4b6c390..ded1e7bcd 100644 --- a/packages/foundation/common/src/node/index.ts +++ b/packages/foundation/common/src/node/index.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'; import { createServer } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import type { ProductChannel } from '@linkcode/schema/daemon-runtime'; import { daemonRuntimeFileSegments } from '@linkcode/schema/daemon-runtime'; /** @@ -34,8 +35,8 @@ export function isPidAlive(pid: number): boolean { } /** Path of the daemon's runtime discovery file under the user's home directory. */ -export function daemonRuntimeFilePath(profile?: string): string { - return join(homedir(), ...daemonRuntimeFileSegments(profile)); +export function daemonRuntimeFilePath(channel: ProductChannel, profile?: string): string { + return join(homedir(), ...daemonRuntimeFileSegments(channel, profile)); } /** Ask the OS for a free loopback port (bind 0, read, close). Check-then-use — the port can be diff --git a/packages/foundation/schema/src/daemon-runtime.ts b/packages/foundation/schema/src/daemon-runtime.ts index 3a54649e5..83173aa91 100644 --- a/packages/foundation/schema/src/daemon-runtime.ts +++ b/packages/foundation/schema/src/daemon-runtime.ts @@ -1,6 +1,10 @@ /** Zero-dependency half of the daemon discovery contract (see `model/daemon-discovery.ts`); * kept zod-free so the sandboxed Electron preload (no `require('zod')`) can import it. */ -import { STATE_DIR_BASENAME } from './product'; +import type { ProductChannel } from './product'; +import { stateDirBasename } from './product'; + +export type { ProductChannel } from './product'; +export { parseProductChannel } from './product'; /** Default TCP port of the local daemon: 0x4C43 — ascii "LC". */ export const DAEMON_DEFAULT_PORT = 19523; @@ -25,16 +29,22 @@ export function parseProfileName(raw: string | undefined): string | undefined { return raw; } -/** The daemon state directory name under the user's home: `.linkcode`, or a profile sibling. - * Validates its input so no caller can interpolate a traversal or separator — safety lives here. */ -export function linkcodeStateDirName(profile?: string): string { +/** The daemon state directory name under the user's home: the channel's base name, or a profile + * sibling of it. Validates its input so no caller can interpolate a traversal or separator — + * safety lives here. `channel` is required: defaulting it would let a missed call site silently + * land a development build in the release universe, which is the whole bug class this prevents. */ +export function linkcodeStateDirName(channel: ProductChannel, profile?: string): string { const parsed = parseProfileName(profile); - return parsed === undefined ? STATE_DIR_BASENAME : `${STATE_DIR_BASENAME}-${parsed}`; + const base = stateDirBasename(channel); + return parsed === undefined ? base : `${base}-${parsed}`; } /** Runtime discovery file the daemon writes after binding, as path segments under the user's home directory. */ -export function daemonRuntimeFileSegments(profile?: string): readonly [string, string] { - return [linkcodeStateDirName(profile), 'runtime.json']; +export function daemonRuntimeFileSegments( + channel: ProductChannel, + profile?: string, +): readonly [string, string] { + return [linkcodeStateDirName(channel, profile), 'runtime.json']; } /** Exit code of a daemon that stood down because a live daemon already serves this profile (see diff --git a/packages/foundation/schema/src/model/daemon-discovery.ts b/packages/foundation/schema/src/model/daemon-discovery.ts index dd4f7545f..ad4c454cf 100644 --- a/packages/foundation/schema/src/model/daemon-discovery.ts +++ b/packages/foundation/schema/src/model/daemon-discovery.ts @@ -14,10 +14,13 @@ export { daemonRuntimeFileSegments, linkcodeStateDirName, PROFILE_NAME_PATTERN, + type ProductChannel, + parseProductChannel, parseProfileName, } from '../daemon-runtime'; import { PROFILE_NAME_PATTERN } from '../daemon-runtime'; +import { PRODUCT_CHANNELS } from '../product'; /** HTTP path every daemon listener answers with its `DaemonIdentity`. */ export const DAEMON_IDENTITY_PATH = '/linkcode'; @@ -29,6 +32,10 @@ export const DaemonIdentitySchema = z.object({ startedAt: TimestampSchema, /** The daemon's profile; absent means the default profile (pre-profile daemons included). */ profile: z.string().regex(PROFILE_NAME_PATTERN).optional(), + /** The daemon's channel; absent means `release` — every pre-CODE-460 daemon predates the split + * and served the universe that is now release's alone. Together with `profile` this identifies + * the state universe, which is what the port hunt compares to tell a double-start from a neighbor. */ + channel: z.enum(PRODUCT_CHANNELS).optional(), }); export type DaemonIdentity = z.infer; diff --git a/packages/foundation/schema/src/product.ts b/packages/foundation/schema/src/product.ts index 2221b3521..3bec12883 100644 --- a/packages/foundation/schema/src/product.ts +++ b/packages/foundation/schema/src/product.ts @@ -2,14 +2,74 @@ * (docs/FORKING.md). Zero-dependency and zod-free so the sandboxed Electron preload can * import it (same contract as `daemon-runtime.ts`). */ -/** Base name of the per-user state directory under `$HOME` (`~/.linkcode`); profiles use - * the `-` sibling — see `linkcodeStateDirName` in `daemon-runtime.ts`. */ -export const STATE_DIR_BASENAME = '.linkcode'; +/** + * Build lineage, and with it an entire parallel on-disk footprint: `development` is any build + * that is not the released app. Every path below forks on it, so a local build can never share + * a state directory, a port, or a `daemon.db` with an installed release (CODE-460). + */ +export const PRODUCT_CHANNELS = ['release', 'development'] as const; + +export type ProductChannel = (typeof PRODUCT_CHANNELS)[number]; + +/** Absent/empty → `undefined` so the caller's own default applies; an unrecognized value throws, + * because a typo in an injected `LINKCODE_CHANNEL` must abort rather than silently fork state. */ +export function parseProductChannel(raw: string | undefined): ProductChannel | undefined { + if (raw === undefined || raw === '') return undefined; + if (!isProductChannel(raw)) { + throw new TypeError( + `invalid channel ${JSON.stringify(raw)}: expected one of ${PRODUCT_CHANNELS.join(', ')}`, + ); + } + return raw; +} + +function isProductChannel(raw: string): raw is ProductChannel { + return (PRODUCT_CHANNELS as readonly string[]).includes(raw); +} + +/** + * The channel a host process runs in: an injected value (the desktop supervisor's + * `LINKCODE_CHANNEL`) outranks the build-time stamp, and a build with neither is a working copy. + * + * Takes two strings rather than an env object on purpose: the stamp reaches a bundle through + * esbuild's `define`, which only substitutes a literal `process.env.LINKCODE_BUILD_CHANNEL` at the + * call site. Passing `process.env` here would defeat it and every bundle would read as development. + */ +export function resolveProductChannel( + injected: string | undefined, + buildStamp: string | undefined, +): ProductChannel { + return parseProductChannel(injected) ?? parseProductChannel(buildStamp) ?? 'development'; +} + +const BRAND = 'LinkCode'; +const DEVELOPMENT_BRAND = `${BRAND} Development`; +const STATE_BRAND = '.linkcode'; + +/** + * Base name of the per-user state directory under `$HOME` (`~/.linkcode`); profiles use the + * `-` sibling — see `linkcodeStateDirName` in `daemon-runtime.ts`. + * + * The development suffix is dot-separated on purpose: `PROFILE_NAME_PATTERN` forbids dots, so + * `.linkcode.development` can never collide with the sibling `--profile=development` would pick. + */ +export function stateDirBasename(channel: ProductChannel): string { + return channel === 'development' ? `${STATE_BRAND}.development` : STATE_BRAND; +} /** Directory under `$HOME` holding user workspaces and the daemon's chat root (`~/LinkCode`). - * Shared across channels and profiles on purpose. */ -export const WORKSPACES_DIRNAME = 'LinkCode'; + * Forks by channel only — profiles of one channel share their workspaces on purpose. */ +export function workspacesDirName(channel: ProductChannel): string { + return channel === 'development' ? DEVELOPMENT_BRAND : BRAND; +} + +/** Directory name under the platform data dir holding the managed-asset store. Mirrors the + * desktop shell's `APP_NAME`, so a channel's store sits beside that channel's `userData`. */ +export function dataDirName(channel: ProductChannel): string { + return channel === 'development' ? DEVELOPMENT_BRAND : BRAND; +} -/** Directory name under the platform data dir holding the managed-asset store; XDG (linux) - * paths use the lowercase form. */ -export const DATA_DIRNAME = 'LinkCode'; +/** XDG (linux) flavour of {@link dataDirName}: lowercase, and hyphenated instead of spaced. */ +export function xdgDataDirName(channel: ProductChannel): string { + return channel === 'development' ? 'linkcode-development' : 'linkcode'; +} diff --git a/packages/host/agent-adapter/src/native/opencode/history-server.ts b/packages/host/agent-adapter/src/native/opencode/history-server.ts index 43576e101..f94daf0b9 100644 --- a/packages/host/agent-adapter/src/native/opencode/history-server.ts +++ b/packages/host/agent-adapter/src/native/opencode/history-server.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { allocatePort } from '@linkcode/common/node'; import { linkcodeStateDirName } from '@linkcode/schema/daemon-runtime'; +import { resolveProductChannel } from '@linkcode/schema/product'; import crossSpawn from 'cross-spawn'; import { extractErrorMessage } from 'foxts/extract-error-message'; @@ -60,6 +61,17 @@ interface ServerGeneration { ready: Promise; } +/** A neutral directory inside this channel's daemon state dir: opencode indexes its cwd as the + * default workspace, so the server must not start anywhere the user keeps code. The build stamp is + * read as a literal because that is what tsup's `define` substitutes (see `resolveProductChannel`). */ +function defaultNeutralCwd(): string { + const channel = resolveProductChannel( + process.env.LINKCODE_CHANNEL, + process.env.LINKCODE_BUILD_CHANNEL, + ); + return join(homedir(), linkcodeStateDirName(channel), 'opencode-history'); +} + /** Exit is read off the process itself — both fields stay null until it is truly gone, so there * is no shadow flag to drift out of sync with reality. */ function processExited(proc: HistoryServerProcess): boolean { @@ -109,8 +121,7 @@ export class OpencodeHistoryServer implements OpencodeHistoryServerLike { constructor(options: OpencodeHistoryServerOptions = {}) { this.spawnServer = options.spawnServer ?? defaultSpawnServer; this.allocatePort = options.allocatePort ?? allocatePort; - this.neutralCwd = - options.neutralCwd ?? join(homedir(), linkcodeStateDirName(), 'opencode-history'); + this.neutralCwd = options.neutralCwd ?? defaultNeutralCwd(); this.idleMs = options.idleMs ?? 60000; this.readyTimeoutMs = options.readyTimeoutMs ?? 30000; this.shutdownGraceMs = options.shutdownGraceMs ?? 5000; diff --git a/packages/host/assets/src/__tests__/paths.test.ts b/packages/host/assets/src/__tests__/paths.test.ts index fa19b0590..c55a7cd1c 100644 --- a/packages/host/assets/src/__tests__/paths.test.ts +++ b/packages/host/assets/src/__tests__/paths.test.ts @@ -3,25 +3,31 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { assetDir, assetsRootFor, versionDir } from '../paths'; const home = '/home/u'; +const channel = 'release'; describe('assetsRootFor', () => { it('prefers the LINKCODE_ASSETS_DIR override on any platform', () => { expect( - assetsRootFor({ platform: 'darwin', env: { LINKCODE_ASSETS_DIR: '/e2e/assets' }, home }), + assetsRootFor({ + platform: 'darwin', + env: { LINKCODE_ASSETS_DIR: '/e2e/assets' }, + home, + channel, + }), ).toBe('/e2e/assets'); }); it('maps darwin to Application Support', () => { - expect(assetsRootFor({ platform: 'darwin', env: {}, home })).toBe( + expect(assetsRootFor({ platform: 'darwin', env: {}, home, channel })).toBe( '/home/u/Library/Application Support/LinkCode/assets', ); }); it('maps linux to XDG_DATA_HOME when set, else ~/.local/share', () => { - expect(assetsRootFor({ platform: 'linux', env: { XDG_DATA_HOME: '/xdg' }, home })).toBe( - '/xdg/linkcode/assets', - ); - expect(assetsRootFor({ platform: 'linux', env: {}, home })).toBe( + expect( + assetsRootFor({ platform: 'linux', env: { XDG_DATA_HOME: '/xdg' }, home, channel }), + ).toBe('/xdg/linkcode/assets'); + expect(assetsRootFor({ platform: 'linux', env: {}, home, channel })).toBe( '/home/u/.local/share/linkcode/assets', ); }); @@ -32,12 +38,28 @@ describe('assetsRootFor', () => { platform: 'win32', env: { LOCALAPPDATA: String.raw`C:\Users\u\AppData\Local` }, home, + channel, }), ).toBe(String.raw`C:\Users\u\AppData\Local/LinkCode/assets`); - expect(assetsRootFor({ platform: 'win32', env: {}, home })).toBe( + expect(assetsRootFor({ platform: 'win32', env: {}, home, channel })).toBe( '/home/u/AppData/Local/LinkCode/assets', ); }); + + // The store is what a boot GC prunes to the running daemon's version pins, so a shared root + // would let a dev daemon delete an installed release's binaries (CODE-460). + it('forks the development channel onto its own root, per platform', () => { + const development = 'development'; + expect(assetsRootFor({ platform: 'darwin', env: {}, home, channel: development })).toBe( + '/home/u/Library/Application Support/LinkCode Development/assets', + ); + expect(assetsRootFor({ platform: 'win32', env: {}, home, channel: development })).toBe( + '/home/u/AppData/Local/LinkCode Development/assets', + ); + expect(assetsRootFor({ platform: 'linux', env: {}, home, channel: development })).toBe( + '/home/u/.local/share/linkcode-development/assets', + ); + }); }); describe('store layout', () => { diff --git a/packages/host/assets/src/paths.ts b/packages/host/assets/src/paths.ts index 75b069d75..56a7027d3 100644 --- a/packages/host/assets/src/paths.ts +++ b/packages/host/assets/src/paths.ts @@ -3,8 +3,8 @@ import { mkdirSync, mkdtempSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import process from 'node:process'; -import type { ManagedAssetId } from '@linkcode/schema'; -import { DATA_DIRNAME } from '@linkcode/schema/product'; +import type { ManagedAssetId, ProductChannel } from '@linkcode/schema'; +import { dataDirName, resolveProductChannel, xdgDataDirName } from '@linkcode/schema/product'; /** * Store layout `////…`: the id's `:` becomes a directory level @@ -17,33 +17,42 @@ interface RootContext { platform: typeof process.platform; env: Record; home: string; + /** Forks the store per channel (CODE-460), so a dev daemon's version pins can never GC an + * installed release's binaries out from under it. */ + channel: ProductChannel; } /** Pure core of {@link assetsRoot}, parameterized for tests. */ export function assetsRootFor(ctx: RootContext): string { const override = ctx.env.LINKCODE_ASSETS_DIR; if (override) return override; + const dirname = dataDirName(ctx.channel); switch (ctx.platform) { case 'darwin': - return join(ctx.home, 'Library', 'Application Support', DATA_DIRNAME, 'assets'); + return join(ctx.home, 'Library', 'Application Support', dirname, 'assets'); case 'win32': - return join( - ctx.env.LOCALAPPDATA ?? join(ctx.home, 'AppData', 'Local'), - DATA_DIRNAME, - 'assets', - ); + return join(ctx.env.LOCALAPPDATA ?? join(ctx.home, 'AppData', 'Local'), dirname, 'assets'); default: return join( ctx.env.XDG_DATA_HOME ?? join(ctx.home, '.local', 'share'), - DATA_DIRNAME.toLowerCase(), + xdgDataDirName(ctx.channel), 'assets', ); } } -/** The per-user store root: `LINKCODE_ASSETS_DIR` wins, else the platform data directory. */ +/** The per-user store root: `LINKCODE_ASSETS_DIR` wins, else the channel's platform data directory. */ export function assetsRoot(): string { - return assetsRootFor({ platform: process.platform, env: process.env, home: homedir() }); + return assetsRootFor({ + platform: process.platform, + env: process.env, + home: homedir(), + channel: resolveProductChannel( + process.env.LINKCODE_CHANNEL, + // Literal on purpose — tsup's `define` substitutes it when this is bundled into the daemon. + process.env.LINKCODE_BUILD_CHANNEL, + ), + }); } export function assetDir(id: ManagedAssetId): string {