Skip to content

Commit 3bcd2ed

Browse files
committed
Tooling: CMDR_INSTANCE_ID primitive (P1/6)
- New apps/desktop/scripts/instance-id.js: pure helpers (sanitizer, instance derivation, identifier / productName / app-data-dir composition, --worktree flag extractor). Vitest-tested in scripts/instance-id.test.js (29 cases). - Rewrite tauri-wrapper.js around the new helpers. It resolves CMDR_INSTANCE_ID (existing env wins, then --worktree-derived dev-<slug>, then "dev" in dev mode, else unset for prod), computes CMDR_DATA_DIR to match Tauri's app_data_dir() for the same identifier, and writes a fresh tauri.instance.json under $TMPDIR/cmdr-tauri-instance-<rand>/ that it passes via -c <abs path>. Cleanup on exit / SIGINT / SIGTERM; /tmp self-prune is the load-bearing fallback. - The generated config also flips withGlobalTauri to true and points the updater at a dead URL (https://localhost.invalid/no-updater) so non-prod instances never phone home. - pnpm dev --worktree foo -- --features virtual-mtp parses correctly: the wrapper extracts the slug before --, leaves --features through to Tauri. - Worktree slug sanitization: lowercase ASCII [a-z0-9-]+, runs of dashes collapsed, trimmed, max 32 chars, retrim after slice. Rejects empty input with a descriptive error (thrown in Node before any Rust process spawns). - Force CMDR_SECRET_STORE=file for any non-prod instance (preserves no-Keychain-dialog dev UX). - Delete apps/desktop/src-tauri/tauri.dev.json: replaced by the wrapper-generated file. - config.rs: extract data_dir_from_env helper so the CMDR_DATA_DIR branch of resolved_app_data_dir is unit-testable without a Tauri mock runtime. Empty CMDR_DATA_DIR is now treated as unset (falls through to Tauri default) instead of silently landing in cwd-equivalent paths. - Vitest config includes scripts/**/*.test.js so the new wrapper tests run. - Doc sweep: AGENTS.md Debugging, CONTRIBUTING.md MTP example, apps/desktop/CLAUDE.md Running, docs/security.md withGlobalTauri, apps/desktop/src-tauri/src/config.rs comment, apps/desktop/src-tauri/src/mtp/CLAUDE.md virtual-mtp recipe. - Prod path bit-for-bit identical: pnpm build skips instance composition entirely, so canonical tauri.conf.json governs the bundle and identifier stays com.veszelovszki.cmdr.
1 parent 216c91f commit 3bcd2ed

13 files changed

Lines changed: 1021 additions & 516 deletions

AGENTS.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,15 @@ Three cadences. Pick the one that matches where you are in the work, not the one
151151

152152
## Debugging
153153

154-
- **Data dirs (dev and prod are separate!)**: Prod: `~/Library/Application Support/com.veszelovszki.cmdr/`, Dev:
155-
`~/Library/Application Support/com.veszelovszki.cmdr-dev/`. The split has two prongs that converge on the same path:
156-
(1) `tauri.dev.json` overrides `identifier` to `com.veszelovszki.cmdr-dev` so Tauri's own `app_data_dir()` (and
157-
anything keyed off it, e.g. `tauri-plugin-store`'s `settings.json`) lands in the dev path; (2) `tauri-wrapper.js` sets
158-
`CMDR_DATA_DIR` to the same path so `src-tauri/src/config.rs::data_dir()` and direct file I/O (crash reports, logs,
159-
the file-backed secret store) all agree without round-tripping through Tauri's API.
154+
- **Data dirs (prod, dev, and dev-per-worktree are separate!)**: Prod:
155+
`~/Library/Application Support/com.veszelovszki.cmdr/`. Dev (plain `pnpm dev`):
156+
`~/Library/Application Support/com.veszelovszki.cmdr-dev/`. A per-worktree dev session started with
157+
`pnpm dev --worktree foo` lives at `~/Library/Application Support/com.veszelovszki.cmdr-dev-foo/`. The wrapper
158+
(`apps/desktop/scripts/tauri-wrapper.js`) resolves `CMDR_INSTANCE_ID` from flags and env, writes a fresh
159+
`tauri.instance.json` under `$TMPDIR` with the matching identifier (so Tauri's own `app_data_dir()` lands on the right
160+
path), and exports `CMDR_DATA_DIR` to the same path so direct file I/O (crash reports, logs, file-backed secret store)
161+
agrees without round-tripping through Tauri's API. See
162+
[`docs/specs/instance-isolation-plan.md`](docs/specs/instance-isolation-plan.md) for the full design.
160163
- **Logging**: Frontend and backend logs appear together in terminal and in the log dir (dev: `<CMDR_DATA_DIR>/logs/`,
161164
prod: `~/Library/Logs/com.veszelovszki.cmdr/`). **Read [docs/tooling/logging.md](docs/tooling/logging.md) before using
162165
`RUST_LOG`**: it has copy-paste recipes for every subsystem. Key gotcha: the Rust library target is `cmdr_lib`, not

CONTRIBUTING.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,13 @@ This starts both the Svelte frontend and the Rust backend with hot reload.
5959
To test with a virtual MTP device (simulated Android phone):
6060

6161
```bash
62-
cd apps/desktop && pnpm tauri dev -c src-tauri/tauri.dev.json --features virtual-mtp
62+
pnpm dev -- --features virtual-mtp
6363
```
6464

65+
This still flows through `apps/desktop/scripts/tauri-wrapper.js`, which generates the per-instance config (bundle
66+
identifier, `CMDR_DATA_DIR`, file-backed secret store) on the fly. Pass `--worktree <slug>` first to isolate a
67+
worktree's data dir from your main dev session: `pnpm dev --worktree foo -- --features virtual-mtp`.
68+
6569
## Debug window
6670

6771
In dev mode, press **Cmd+D** to open a debug window. This window is only available in dev builds and provides:

apps/desktop/CLAUDE.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@ the full subsystem map. Feature-level docs live in colocated `CLAUDE.md` files n
1010
Run from repo root: `pnpm dev` (start) and `pnpm build` (release build). Don't `cd` here just to run the app.
1111

1212
If you've already `cd`d into `apps/desktop/`, `pnpm tauri dev` is equivalent; both paths invoke
13-
`scripts/tauri-wrapper.js`, which sets `CMDR_DATA_DIR` and injects `tauri.dev.json`. The wrapper is the single source of
14-
truth for dev/prod path separation; bypassing it (raw `cargo tauri dev`, raw `cargo build`) gives you the wrong data dir
15-
or a binary with no embedded frontend.
13+
`scripts/tauri-wrapper.js`, which resolves `CMDR_INSTANCE_ID` (from `--worktree <slug>` or the existing env, else
14+
`"dev"`), composes `CMDR_DATA_DIR` to match, and writes a fresh `tauri.instance.json` under `$TMPDIR` that overrides the
15+
bundle identifier and `productName` for this instance. The wrapper is the single source of truth for dev / prod path
16+
separation; bypassing it (raw `cargo tauri dev`, raw `cargo build`) gives you the wrong data dir or a binary with no
17+
embedded frontend.
18+
19+
To run two dev sessions side-by-side from different worktrees without colliding on `settings.json`, ports, or the Dock
20+
label, pass `--worktree <slug>`: `pnpm dev --worktree my-feature`. The slug is sanitized to lowercase ASCII (max 32
21+
chars) and feeds into both the bundle identifier (`com.veszelovszki.cmdr-dev-my-feature`) and the data dir. See
22+
[`docs/specs/instance-isolation-plan.md`](../../docs/specs/instance-isolation-plan.md) for the full design.
1623

1724
## Structure
1825

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Pure helpers for the CMDR_INSTANCE_ID primitive. Kept side-effect-free so vitest can
2+
// exercise them without spawning Tauri. See docs/specs/instance-isolation-plan.md for the
3+
// full design.
4+
//
5+
// One env var (CMDR_INSTANCE_ID) drives every per-instance suffix the wrapper composes:
6+
// - prod (pnpm build): unset
7+
// - pnpm dev (no --worktree): "dev"
8+
// - pnpm dev --worktree foo: "dev-<sanitized slug>"
9+
// - E2E checker (set externally): preserved as-is
10+
//
11+
// The wrapper derives CMDR_DATA_DIR, bundle identifier, productName, and (in later phases)
12+
// Vite port + MCP ports from this single string.
13+
14+
import { join } from 'path'
15+
16+
const PROD_IDENTIFIER = 'com.veszelovszki.cmdr'
17+
const PROD_PRODUCT_NAME = 'Cmdr'
18+
const MAX_SLUG_LEN = 32
19+
20+
/**
21+
* Sanitize a --worktree slug to lowercase ASCII [a-z0-9-], collapsed dashes, trimmed, max 32 chars.
22+
* The wrapper does NOT validate the slug against the actual worktree directory name. The user
23+
* picks their own slug; this just makes sure the result is safe for a CFBundleIdentifier.
24+
*
25+
* Returns the sanitized slug, or null if the input collapses to empty (caller throws).
26+
*
27+
* @param {unknown} raw
28+
* @returns {string|null}
29+
*/
30+
export function sanitizeWorktreeSlug(raw) {
31+
if (typeof raw !== 'string') return null
32+
// Lowercase, replace any non-[a-z0-9-] with '-', collapse runs, trim leading/trailing '-'.
33+
const cleaned = raw
34+
.toLowerCase()
35+
.replace(/[^a-z0-9-]+/g, '-')
36+
.replace(/-+/g, '-')
37+
.replace(/^-|-$/g, '')
38+
.slice(0, MAX_SLUG_LEN)
39+
.replace(/-+$/, '') // re-trim in case slice cut mid-run
40+
return cleaned.length > 0 ? cleaned : null
41+
}
42+
43+
/**
44+
* Derive CMDR_INSTANCE_ID for a dev or prod invocation.
45+
*
46+
* Precedence:
47+
* 1. existing env (caller responsible for setting, e.g. the E2E checker)
48+
* 2. --worktree <slug> in dev mode → "dev-<sanitized>"
49+
* 3. dev mode with no flag → "dev"
50+
* 4. prod / anything else → null (caller leaves the env unset)
51+
*
52+
* @param {object} opts
53+
* @param {boolean} opts.isDev
54+
* @param {string|undefined} opts.envInstanceId the current value of CMDR_INSTANCE_ID, if any
55+
* @param {string|null|undefined} opts.worktreeSlug raw --worktree argument (pre-sanitization)
56+
* @returns {string|null}
57+
*/
58+
export function resolveInstanceId({ isDev, envInstanceId, worktreeSlug }) {
59+
if (envInstanceId && envInstanceId.length > 0) return envInstanceId
60+
if (!isDev) return null
61+
if (worktreeSlug != null) {
62+
const slug = sanitizeWorktreeSlug(worktreeSlug)
63+
if (slug === null) {
64+
throw new Error(
65+
`--worktree must be 1-${MAX_SLUG_LEN} alphanumeric or dash characters after sanitization (got: ${JSON.stringify(worktreeSlug)})`,
66+
)
67+
}
68+
return `dev-${slug}`
69+
}
70+
return 'dev'
71+
}
72+
73+
/**
74+
* Compute the Tauri-equivalent app_data_dir for an identifier on this OS. Mirrors the
75+
* platform branches in resolved_app_data_dir on the Rust side and the legacy block this
76+
* file replaces in tauri-wrapper.js.
77+
*
78+
* @param {object} opts
79+
* @param {string} opts.identifier e.g. com.veszelovszki.cmdr-dev
80+
* @param {NodeJS.Platform} opts.platform
81+
* @param {string} opts.home homedir()
82+
* @param {string|undefined} opts.xdgDataHome process.env.XDG_DATA_HOME
83+
* @returns {string}
84+
*/
85+
export function computeAppDataDir({ identifier, platform, home, xdgDataHome }) {
86+
if (platform === 'darwin') {
87+
return join(home, 'Library', 'Application Support', identifier)
88+
}
89+
const base = xdgDataHome && xdgDataHome.length > 0 ? xdgDataHome : join(home, '.local', 'share')
90+
return join(base, identifier)
91+
}
92+
93+
/**
94+
* Compose the bundle identifier from an instance ID. Unset → prod default.
95+
*
96+
* @param {string|null} instanceId
97+
* @returns {string}
98+
*/
99+
export function bundleIdentifier(instanceId) {
100+
return instanceId ? `${PROD_IDENTIFIER}-${instanceId}` : PROD_IDENTIFIER
101+
}
102+
103+
/**
104+
* Compose the Dock / process label (productName) from an instance ID. Unset → "Cmdr".
105+
*
106+
* @param {string|null} instanceId
107+
* @returns {string}
108+
*/
109+
export function productName(instanceId) {
110+
return instanceId ? `Cmdr (${instanceId})` : PROD_PRODUCT_NAME
111+
}
112+
113+
/**
114+
* Pull a --worktree value out of an argv array, returning { slug, rest }.
115+
* - Honors the `--` separator: anything after it is left as-is for Tauri.
116+
* - Recognizes `--worktree foo` and `--worktree=foo`.
117+
* - Removes the consumed tokens from the returned `rest`.
118+
*
119+
* @param {string[]} argv
120+
* @returns {{ slug: string|null, rest: string[] }}
121+
*/
122+
export function extractWorktreeFlag(argv) {
123+
const sepIdx = argv.indexOf('--')
124+
const beforeSep = sepIdx >= 0 ? argv.slice(0, sepIdx) : argv.slice()
125+
const afterSep = sepIdx >= 0 ? argv.slice(sepIdx) : []
126+
127+
let slug = null
128+
const kept = []
129+
for (let i = 0; i < beforeSep.length; i++) {
130+
const a = beforeSep[i]
131+
if (a === '--worktree') {
132+
slug = beforeSep[i + 1] ?? null
133+
i += 1 // skip the value
134+
continue
135+
}
136+
if (a.startsWith('--worktree=')) {
137+
slug = a.slice('--worktree='.length)
138+
continue
139+
}
140+
kept.push(a)
141+
}
142+
return { slug, rest: [...kept, ...afterSep] }
143+
}
144+
145+
/**
146+
* @typedef {{
147+
* $schema: string,
148+
* productName: string,
149+
* identifier: string,
150+
* app: { withGlobalTauri: boolean },
151+
* plugins: { updater: { endpoints: string[] } },
152+
* }} InstanceConfig
153+
*/
154+
155+
/**
156+
* Build the Tauri config payload that the wrapper writes to disk and passes via -c.
157+
*
158+
* For prod (instanceId null), returns null: the wrapper omits -c entirely so canonical
159+
* tauri.conf.json governs the build.
160+
*
161+
* @param {string|null} instanceId
162+
* @returns {InstanceConfig|null}
163+
*/
164+
export function buildInstanceConfig(instanceId) {
165+
if (!instanceId) return null
166+
return {
167+
$schema: 'https://schema.tauri.app/config/2',
168+
productName: productName(instanceId),
169+
identifier: bundleIdentifier(instanceId),
170+
app: {
171+
withGlobalTauri: true,
172+
},
173+
plugins: {
174+
updater: {
175+
// Dead URL so non-prod instances never phone home accidentally. P4 will replace
176+
// this with a real per-instance stub when the Vite dev port also lands here.
177+
endpoints: ['https://localhost.invalid/no-updater'],
178+
},
179+
},
180+
}
181+
}
182+
183+
/**
184+
* Convenience for tests + the wrapper: from an instance ID, compute the (identifier, data dir,
185+
* config payload) triple in one place so the precedence rules can't drift.
186+
*
187+
* @param {object} opts
188+
* @param {string|null} opts.instanceId
189+
* @param {NodeJS.Platform} opts.platform
190+
* @param {string} opts.home
191+
* @param {string|undefined} opts.xdgDataHome
192+
* @returns {{ identifier: string, dataDir: string, config: InstanceConfig|null }}
193+
*/
194+
export function deriveInstance({ instanceId, platform, home, xdgDataHome }) {
195+
const identifier = bundleIdentifier(instanceId)
196+
const dataDir = computeAppDataDir({ identifier, platform, home, xdgDataHome })
197+
const config = buildInstanceConfig(instanceId)
198+
return { identifier, dataDir, config }
199+
}
200+
201+
// Re-export the platform default for callers that need to detect "no override".
202+
export const PRODUCTION_IDENTIFIER = PROD_IDENTIFIER

0 commit comments

Comments
 (0)