mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
2 Commits
v0.3.31
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5952c201d3 | ||
|
|
b63ce394c6 |
@@ -489,6 +489,25 @@ VITE_API_URL=http://localhost:<backend-port>
|
||||
VITE_WS_URL=ws://localhost:<backend-port>/ws
|
||||
```
|
||||
|
||||
#### Running multiple worktrees side-by-side
|
||||
|
||||
`pnpm dev:desktop` auto-isolates a worktree so several worktrees can run their
|
||||
own desktop dev instance at once — no extra setup. From a linked worktree it
|
||||
derives, from the worktree path (same `cksum % 1000` offset as the backend /
|
||||
frontend ports in `.env.worktree`):
|
||||
|
||||
- `DESKTOP_RENDERER_PORT` = `5174 + offset` — its own Vite dev server (`5174`
|
||||
base leaves `5173` for the primary checkout, even when `offset` is `0`)
|
||||
- `DESKTOP_APP_SUFFIX` = `<folder>-<offset>` — its own single-instance lock /
|
||||
`userData`, and an app named `Multica Canary <folder>-<offset>` so it is
|
||||
distinguishable in Cmd+Tab. The offset keeps it unique across worktrees that
|
||||
share a folder name at different paths.
|
||||
|
||||
The primary checkout is left untouched (`5173`, `Multica Canary`). Set either
|
||||
env var explicitly to override the derived value. Which backend each instance
|
||||
talks to is still controlled only by `apps/desktop/.env*` above — point each
|
||||
worktree's desktop at its own backend to also isolate the daemon profile.
|
||||
|
||||
### Isolation Guarantee
|
||||
|
||||
Nothing in this flow touches the system-installed `multica` or the default
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"scripts": {
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
|
||||
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
|
||||
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"dev:staging": "node scripts/dev.mjs --mode staging",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
// matches. The patch is isolated to this worktree's node_modules — we
|
||||
// unlink the file before rewriting so we never mutate a pnpm-store inode
|
||||
// shared with another project.
|
||||
//
|
||||
// In a worktree, scripts/dev.mjs sets DESKTOP_APP_SUFFIX so the name becomes
|
||||
// "Multica Canary <suffix>" — distinguishable in Cmd+Tab and matching the app
|
||||
// name src/main/index.ts derives from the same env var.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
@@ -17,7 +21,9 @@ import { resolve } from "node:path";
|
||||
|
||||
if (process.platform !== "darwin") process.exit(0);
|
||||
|
||||
const DESIRED_NAME = "Multica Canary";
|
||||
const DESIRED_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
? `Multica Canary ${process.env.DESKTOP_APP_SUFFIX}`
|
||||
: "Multica Canary";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// `require('electron')` returns the path to the executable
|
||||
|
||||
53
apps/desktop/scripts/dev.mjs
Normal file
53
apps/desktop/scripts/dev.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// Dev launcher for `pnpm dev:desktop`.
|
||||
//
|
||||
// Derives per-worktree isolation env (renderer port + app name) so multiple
|
||||
// worktrees can run `pnpm dev:desktop` side-by-side, then runs the same chain
|
||||
// as before — bundle the CLI, brand the dev Electron, start electron-vite —
|
||||
// inheriting the augmented env. A plain `&&` chain in package.json can't do
|
||||
// this: each `&&` step is its own process, so an env tweak in step 1 wouldn't
|
||||
// reach electron-vite in step 3. Args (e.g. `--mode staging`) pass through to
|
||||
// electron-vite.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
applyWorktreeDevEnv,
|
||||
repoRootFromScriptDir,
|
||||
} from "./worktree-dev-env.mjs";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
applyWorktreeDevEnv(process.env, {
|
||||
root: repoRootFromScriptDir(here),
|
||||
log: true,
|
||||
});
|
||||
|
||||
function run(command, args, { shell = false } = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell,
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`[dev:desktop] failed to run ${command}: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
const node = process.execPath;
|
||||
run(node, [join(here, "bundle-cli.mjs")]);
|
||||
run(node, [join(here, "brand-dev-electron.mjs")]);
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
const electronVite = join(
|
||||
here,
|
||||
"..",
|
||||
"node_modules",
|
||||
".bin",
|
||||
isWin ? "electron-vite.cmd" : "electron-vite",
|
||||
);
|
||||
run(electronVite, ["dev", ...process.argv.slice(2)], { shell: isWin });
|
||||
116
apps/desktop/scripts/worktree-dev-env.mjs
Normal file
116
apps/desktop/scripts/worktree-dev-env.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
// Per-worktree dev isolation for `pnpm dev:desktop`.
|
||||
//
|
||||
// Two `pnpm dev:desktop` instances from two different git worktrees collide on
|
||||
// the renderer Vite port (5173) and the single-instance lock / userData dir
|
||||
// (keyed by the app name "Multica Canary"). The env hooks to override both
|
||||
// already exist — electron.vite.config.ts reads DESKTOP_RENDERER_PORT and
|
||||
// src/main/index.ts reads DESKTOP_APP_SUFFIX — but nothing derives unique
|
||||
// values per worktree. This module does, mirroring the offset scheme that
|
||||
// scripts/init-worktree-env.sh already uses for backend/frontend ports.
|
||||
//
|
||||
// Backend targeting is deliberately NOT touched here: which backend the desktop
|
||||
// connects to stays driven by apps/desktop/.env* (VITE_API_URL / VITE_WS_URL),
|
||||
// exactly as documented. This module only adds the two knobs needed for two
|
||||
// Electron processes to coexist.
|
||||
|
||||
import { statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
// Worktree renderer ports start at 5174 so they never reuse 5173 — the primary
|
||||
// checkout's default — even when a worktree's offset is 0 (e.g. POSIX cksum of
|
||||
// "/tmp/multica-3494" is 1189739000, and 1189739000 % 1000 === 0). Range 5174–6173.
|
||||
const RENDERER_PORT_BASE = 5174;
|
||||
const OFFSET_MODULO = 1000;
|
||||
|
||||
// POSIX cksum (CRC-32), kept byte-compatible with `cksum(1)` so the offset
|
||||
// matches scripts/init-worktree-env.sh — a worktree's backend (18080+offset),
|
||||
// frontend (13000+offset) and desktop renderer (5174+offset) ports all share
|
||||
// one offset. Verified against coreutils: cksum of "/tmp/foo" → 427878967.
|
||||
function cksumTable() {
|
||||
const table = new Uint32Array(256);
|
||||
const POLY = 0x04c11db7;
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = i << 24;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = crc & 0x80000000 ? (crc << 1) ^ POLY : crc << 1;
|
||||
}
|
||||
table[i] = crc >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const TABLE = cksumTable();
|
||||
|
||||
export function cksum(buf) {
|
||||
let crc = 0;
|
||||
for (const byte of buf) {
|
||||
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
|
||||
}
|
||||
// POSIX appends the byte length, least-significant byte first.
|
||||
let len = buf.length;
|
||||
while (len > 0) {
|
||||
crc = (((crc << 8) >>> 0) ^ TABLE[((crc >>> 24) ^ (len & 0xff)) & 0xff]) >>> 0;
|
||||
len = Math.floor(len / 256);
|
||||
}
|
||||
return (~crc) >>> 0;
|
||||
}
|
||||
|
||||
export function offsetForPath(path) {
|
||||
return cksum(Buffer.from(path)) % OFFSET_MODULO;
|
||||
}
|
||||
|
||||
export function rendererPortForPath(path) {
|
||||
return RENDERER_PORT_BASE + offsetForPath(path);
|
||||
}
|
||||
|
||||
// Worktree → a readable, unique, filesystem-safe suffix "<folder>-<offset>".
|
||||
// The dev app then shows e.g. "Multica Canary mul-3724-194" in Cmd+Tab and gets
|
||||
// its own userData / single-instance lock under that name. The offset is what
|
||||
// makes the lock unique: the folder name alone collides for worktrees that share
|
||||
// a basename at different paths (e.g. /a/multica vs /b/multica) or whose names
|
||||
// slug to the same fallback — those would share one lock and the second Electron
|
||||
// would still be blocked.
|
||||
export function appSuffixForPath(path) {
|
||||
const slug =
|
||||
basename(path)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "worktree";
|
||||
return `${slug}-${offsetForPath(path)}`;
|
||||
}
|
||||
|
||||
// A linked git worktree has a `.git` FILE (a "gitdir:" pointer); the primary
|
||||
// checkout has a `.git` DIRECTORY. We only auto-isolate linked worktrees, so
|
||||
// the primary checkout keeps the unchanged 5173 / "Multica Canary" defaults.
|
||||
export function isLinkedWorktree(root) {
|
||||
try {
|
||||
return statSync(join(root, ".git")).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// scripts live at <root>/apps/desktop/scripts
|
||||
export function repoRootFromScriptDir(scriptDir) {
|
||||
return join(scriptDir, "..", "..", "..");
|
||||
}
|
||||
|
||||
// Populate DESKTOP_RENDERER_PORT / DESKTOP_APP_SUFFIX on `env` for a worktree
|
||||
// checkout, without overriding values the caller set explicitly. Returns `env`.
|
||||
export function applyWorktreeDevEnv(env, { root, log = false } = {}) {
|
||||
const hasPort = Boolean(env.DESKTOP_RENDERER_PORT);
|
||||
const hasSuffix = Boolean(env.DESKTOP_APP_SUFFIX);
|
||||
if (hasPort && hasSuffix) return env; // explicit overrides win outright
|
||||
if (!isLinkedWorktree(root)) return env; // primary checkout → keep defaults
|
||||
|
||||
if (!hasPort) env.DESKTOP_RENDERER_PORT = String(rendererPortForPath(root));
|
||||
if (!hasSuffix) env.DESKTOP_APP_SUFFIX = appSuffixForPath(root);
|
||||
|
||||
if (log) {
|
||||
console.log(
|
||||
`[dev:desktop] worktree isolation → renderer port ${env.DESKTOP_RENDERER_PORT}, ` +
|
||||
`app "Multica Canary ${env.DESKTOP_APP_SUFFIX}"`,
|
||||
);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
101
apps/desktop/scripts/worktree-dev-env.test.mjs
Normal file
101
apps/desktop/scripts/worktree-dev-env.test.mjs
Normal file
@@ -0,0 +1,101 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
appSuffixForPath,
|
||||
applyWorktreeDevEnv,
|
||||
cksum,
|
||||
offsetForPath,
|
||||
rendererPortForPath,
|
||||
} from "./worktree-dev-env.mjs";
|
||||
|
||||
const cleanups = [];
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()();
|
||||
});
|
||||
|
||||
function tmpRoot(kind /* "file" | "dir" | "none" */) {
|
||||
const root = mkdtempSync(join(tmpdir(), "wt-"));
|
||||
cleanups.push(() => rmSync(root, { recursive: true, force: true }));
|
||||
if (kind === "file") writeFileSync(join(root, ".git"), "gitdir: /elsewhere\n");
|
||||
else if (kind === "dir") mkdirSync(join(root, ".git"));
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("worktree-dev-env", () => {
|
||||
it("cksum is byte-compatible with coreutils cksum(1)", () => {
|
||||
// `printf '%s' "/tmp/foo" | cksum` → 427878967 8
|
||||
expect(cksum(Buffer.from("/tmp/foo"))).toBe(427878967);
|
||||
// `printf '' | cksum` → 4294967295 0
|
||||
expect(cksum(Buffer.from(""))).toBe(4294967295);
|
||||
});
|
||||
|
||||
it("derives the offset from the path, mod 1000", () => {
|
||||
expect(offsetForPath("/tmp/foo")).toBe(427878967 % 1000);
|
||||
});
|
||||
|
||||
it("renderer port is 5174 + offset (5173 reserved for the primary checkout)", () => {
|
||||
expect(rendererPortForPath("/tmp/foo")).toBe(5174 + (427878967 % 1000));
|
||||
});
|
||||
|
||||
it("never reuses 5173 even when the offset is 0", () => {
|
||||
// POSIX cksum("/tmp/multica-3494") === 1189739000, % 1000 === 0
|
||||
expect(offsetForPath("/tmp/multica-3494")).toBe(0);
|
||||
expect(rendererPortForPath("/tmp/multica-3494")).toBe(5174);
|
||||
expect(rendererPortForPath("/tmp/multica-3494")).not.toBe(5173);
|
||||
});
|
||||
|
||||
it("suffix is '<folder>-<offset>' so it stays recognizable and unique", () => {
|
||||
expect(appSuffixForPath("/work/MUL-3724_Desktop")).toBe(
|
||||
`mul-3724-desktop-${offsetForPath("/work/MUL-3724_Desktop")}`,
|
||||
);
|
||||
expect(appSuffixForPath("/work/feat/some thing")).toBe(
|
||||
`some-thing-${offsetForPath("/work/feat/some thing")}`,
|
||||
);
|
||||
// empty/non-ascii slug falls back to "worktree", still disambiguated by offset
|
||||
expect(appSuffixForPath("/work/___")).toBe(`worktree-${offsetForPath("/work/___")}`);
|
||||
});
|
||||
|
||||
it("disambiguates worktrees that share a folder name at different paths", () => {
|
||||
// Same basename "multica", different parent dirs → different offsets/suffixes,
|
||||
// so each gets its own single-instance lock.
|
||||
expect(offsetForPath("/tmp/a/multica")).not.toBe(offsetForPath("/tmp/b/multica"));
|
||||
expect(appSuffixForPath("/tmp/a/multica")).not.toBe(
|
||||
appSuffixForPath("/tmp/b/multica"),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-isolates a linked worktree (.git is a file)", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = {};
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe(String(rendererPortForPath(root)));
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
|
||||
});
|
||||
|
||||
it("leaves the primary checkout untouched (.git is a dir)", () => {
|
||||
const root = tmpRoot("dir");
|
||||
const env = {};
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBeUndefined();
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects explicit env overrides", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = { DESKTOP_RENDERER_PORT: "9999", DESKTOP_APP_SUFFIX: "manual" };
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe("manual");
|
||||
});
|
||||
|
||||
it("fills only the missing knob when one is set explicitly", () => {
|
||||
const root = tmpRoot("file");
|
||||
const env = { DESKTOP_RENDERER_PORT: "9999" };
|
||||
applyWorktreeDevEnv(env, { root });
|
||||
expect(env.DESKTOP_RENDERER_PORT).toBe("9999");
|
||||
expect(env.DESKTOP_APP_SUFFIX).toBe(appSuffixForPath(root));
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@
|
||||
"COMPOSE_PROJECT_NAME",
|
||||
"POSTGRES_DB",
|
||||
"POSTGRES_PORT",
|
||||
"DESKTOP_RENDERER_PORT"
|
||||
"DESKTOP_RENDERER_PORT",
|
||||
"DESKTOP_APP_SUFFIX"
|
||||
],
|
||||
"tasks": {
|
||||
"build": {
|
||||
|
||||
Reference in New Issue
Block a user