From a5320f0230fc78652082604d2f6c111fd21aa1ce Mon Sep 17 00:00:00 2001
From: Dan Lynch
Date: Mon, 21 Sep 2026 08:04:01 +0000
Subject: [PATCH 1/3] fix(settings,layout): import keeps the local OSC target;
inline layouts export as JSON, not the default preset
---
packages/cli/src/commands/project-io.ts | 3 ++
.../src/renderer/routes/transfer-dialogs.tsx | 1 +
packages/desktop/src/types/ipc.ts | 2 ++
packages/layout/__tests__/config-env.test.ts | 16 +++++++++
packages/layout/src/config-env.ts | 6 +++-
packages/layout/src/config.ts | 15 ++++++--
packages/settings/__tests__/portable.test.ts | 34 +++++++++++++++++++
packages/settings/src/portable.ts | 19 +++++++++--
8 files changed, 91 insertions(+), 5 deletions(-)
diff --git a/packages/cli/src/commands/project-io.ts b/packages/cli/src/commands/project-io.ts
index 67ad049..66b0e04 100644
--- a/packages/cli/src/commands/project-io.ts
+++ b/packages/cli/src/commands/project-io.ts
@@ -79,6 +79,9 @@ export function runProjectsImport(flags: Flags, file: string | undefined): void
console.log('');
console.log(c.green(` ✓ Imported project ${c.bold(result.project)}`));
console.log(` ${c.gray(`devices: ${result.deviceCount} · users: ${result.userCount}`)}`);
+ if (result.keptLocalOsc) {
+ console.log(` ${c.gray('kept this machine’s OSC target — the bundle had none')}`);
+ }
if (result.generatedSecrets) {
console.log('');
console.log(c.yellow(' ⚠ The bundle had no secrets — fresh receiverKey/jwtSecret were generated.'));
diff --git a/packages/desktop/src/renderer/routes/transfer-dialogs.tsx b/packages/desktop/src/renderer/routes/transfer-dialogs.tsx
index f4047c2..1a93d79 100644
--- a/packages/desktop/src/renderer/routes/transfer-dialogs.tsx
+++ b/packages/desktop/src/renderer/routes/transfer-dialogs.tsx
@@ -207,6 +207,7 @@ export function ImportProjectDialog({
Imported “{result.project}” — {result.deviceCount} device(s), {result.userCount} user(s).
{result.generatedSecrets &&
' The bundle carried no secrets, so fresh ones were generated — they will not match the brain until synced.'}
+ {result.keptLocalOsc && ' This machine’s OSC target was kept (the bundle had none).'}
)}
diff --git a/packages/desktop/src/types/ipc.ts b/packages/desktop/src/types/ipc.ts
index 7d30b1c..c1fd106 100644
--- a/packages/desktop/src/types/ipc.ts
+++ b/packages/desktop/src/types/ipc.ts
@@ -103,6 +103,8 @@ export interface ImportSummary {
generatedSecrets: boolean;
deviceCount: number;
userCount: number;
+ /** True when overwriting kept this machine's OSC target because the bundle had none. */
+ keptLocalOsc: boolean;
path: string;
}
diff --git a/packages/layout/__tests__/config-env.test.ts b/packages/layout/__tests__/config-env.test.ts
index 3f88462..22e3409 100644
--- a/packages/layout/__tests__/config-env.test.ts
+++ b/packages/layout/__tests__/config-env.test.ts
@@ -19,6 +19,22 @@ describe('configEnvMap', () => {
expect(env.WAVEGRID_LAYOUT).toBe('grace-cathedral');
});
+ it('exports an inline layout as JSON instead of the stale default preset', () => {
+ const inline = {
+ kind: 'rings' as const,
+ id: 'grace-28',
+ rings: [{ count: 12, radius: 1, phase: 15 }, { count: 12, radius: 0.62 }, { count: 4, radius: 0.25 }]
+ };
+ // A store layer merged over DEFAULT_CONFIG keeps the defaults' preset alongside `kind`.
+ const env = configEnvMap({ ...DEFAULT_CONFIG, layout: { ...DEFAULT_CONFIG.layout, ...inline } });
+ expect(env.WAVEGRID_LAYOUT).not.toBe('grid-7x7');
+ expect(JSON.parse(env.WAVEGRID_LAYOUT)).toMatchObject(inline);
+
+ const resolved = loadWavegridConfig({ cwd: '/', env: { WAVEGRID_LAYOUT: env.WAVEGRID_LAYOUT } });
+ expect(resolved.layout.count).toBe(28);
+ expect(resolved.layout.id).toBe('grace-28');
+ });
+
it('projects an FB4 target', () => {
const env = configEnvMap({ ...DEFAULT_CONFIG, osc: { fb4: { host: '192.168.1.40', port: 8000 } } });
expect(env.FB4_HOST).toBe('192.168.1.40');
diff --git a/packages/layout/src/config-env.ts b/packages/layout/src/config-env.ts
index 0a0f6d7..110c267 100644
--- a/packages/layout/src/config-env.ts
+++ b/packages/layout/src/config-env.ts
@@ -41,7 +41,11 @@ export function configEnvMap(config: WavegridConfig): Record {
if (v !== undefined && v !== '') env[k] = String(v);
};
- if (config.layout.preset) set('WAVEGRID_LAYOUT', config.layout.preset);
+ // An inline shape (`kind`) wins over `preset` in resolveLayout, and the
+ // defaults' preset survives the merge underneath it — so export the whole
+ // spec as JSON rather than that stale preset id.
+ if (config.layout.kind) set('WAVEGRID_LAYOUT', JSON.stringify(config.layout));
+ else if (config.layout.preset) set('WAVEGRID_LAYOUT', config.layout.preset);
set('WAVEGRID_MODE', config.mode);
set('WAVEGRID_HOST', config.server.host);
set('WAVEGRID_PORT', config.server.port);
diff --git a/packages/layout/src/config.ts b/packages/layout/src/config.ts
index 7514d83..dddd652 100644
--- a/packages/layout/src/config.ts
+++ b/packages/layout/src/config.ts
@@ -1,7 +1,7 @@
import { createConfigLoader } from 'confstash';
import { resolveLayout } from './presets';
-import { Layout, RunMode, WavegridConfig } from './types';
+import { Layout, LayoutSpec, RunMode, WavegridConfig } from './types';
/**
* BEYOND's factory OSC receive port (`[OSC] PortIn` in BEYOND.ini). Every
@@ -45,6 +45,17 @@ function toFloat(value: string | undefined): number | undefined {
return Number.isFinite(n) ? n : undefined;
}
+/** `WAVEGRID_LAYOUT` is a preset id, or a JSON LayoutSpec for inline shapes. */
+function parseLayoutEnv(value: string): LayoutSpec {
+ const text = value.trim();
+ if (!text.startsWith('{')) return { preset: text };
+ const spec = JSON.parse(text) as LayoutSpec;
+ if (!spec || typeof spec !== 'object' || (!spec.kind && !spec.preset)) {
+ throw new Error(`WAVEGRID_LAYOUT JSON must be a layout spec with "kind" or "preset", got: ${text}`);
+ }
+ return spec;
+}
+
/**
* Map environment variables into a config layer. Env sits just below CLI
* overrides so a single build can be re-pointed at another layout at runtime.
@@ -52,7 +63,7 @@ function toFloat(value: string | undefined): number | undefined {
function envLayer(env: NodeJS.ProcessEnv): Partial {
const out: Partial = {};
- if (env.WAVEGRID_LAYOUT) out.layout = { preset: env.WAVEGRID_LAYOUT };
+ if (env.WAVEGRID_LAYOUT) out.layout = parseLayoutEnv(env.WAVEGRID_LAYOUT);
if (env.WAVEGRID_MODE === 'simple' || env.WAVEGRID_MODE === 'distributed' || env.WAVEGRID_MODE === 'auto') {
out.mode = env.WAVEGRID_MODE;
}
diff --git a/packages/settings/__tests__/portable.test.ts b/packages/settings/__tests__/portable.test.ts
index f33c0d7..78577e6 100644
--- a/packages/settings/__tests__/portable.test.ts
+++ b/packages/settings/__tests__/portable.test.ts
@@ -113,6 +113,40 @@ describe('portable project import (round-trip)', () => {
expect(() => src.importProject(bundle, { overwrite: true })).not.toThrow();
});
+ it('overwrite keeps the local OSC target when the bundle has none', () => {
+ const base = tmpBase();
+ const local = seedProject(base);
+ local.saveProjectConfig('ring-demo', {
+ layout: { preset: 'nova' },
+ osc: { beyond: { host: '10.0.0.5', port: 8000, gridOrder: 'row' } }
+ } as never);
+
+ const bundle = seedProject(tmpBase()).exportProject('ring-demo');
+ bundle.config = { layout: { preset: 'grace-cathedral' } } as never;
+
+ const result = local.importProject(bundle, { overwrite: true });
+ expect(result.keptLocalOsc).toBe(true);
+ const cfg = local.getProjectConfig('ring-demo')!;
+ expect(cfg.layout).toEqual({ preset: 'grace-cathedral' });
+ expect(cfg.osc?.beyond?.host).toBe('10.0.0.5');
+ });
+
+ it('overwrite takes the bundle’s OSC target when it has one', () => {
+ const base = tmpBase();
+ const local = seedProject(base);
+ local.saveProjectConfig('ring-demo', {
+ layout: { preset: 'nova' },
+ osc: { beyond: { host: '10.0.0.5', port: 8000, gridOrder: 'row' } }
+ } as never);
+
+ const bundle = seedProject(tmpBase()).exportProject('ring-demo');
+ bundle.config = { layout: { preset: 'nova' }, osc: { beyond: { host: '10.0.0.9', port: 8000, gridOrder: 'row' } } } as never;
+
+ const result = local.importProject(bundle, { overwrite: true });
+ expect(result.keptLocalOsc).toBe(false);
+ expect(local.getProjectConfig('ring-demo')!.osc?.beyond?.host).toBe('10.0.0.9');
+ });
+
it('can import under a new name', () => {
const src = seedProject(tmpBase());
const bundle = src.exportProject('ring-demo');
diff --git a/packages/settings/src/portable.ts b/packages/settings/src/portable.ts
index dbb06e9..84d9d01 100644
--- a/packages/settings/src/portable.ts
+++ b/packages/settings/src/portable.ts
@@ -105,6 +105,8 @@ export interface ImportResult {
generatedSecrets: boolean;
deviceCount: number;
userCount: number;
+ /** True when an overwrite kept this machine's `osc` block because the bundle had none. */
+ keptLocalOsc: boolean;
}
/** Validate an untrusted object as a PortableProject bundle. */
@@ -141,7 +143,20 @@ export function importProject(paths: StorePaths, bundle: PortableProject, opts:
throw new Error(`Project "${project}" already exists — pass overwrite to replace it, or import under a new name.`);
}
- createProject(paths, project, bundle.config, { activate: opts.activate });
+ // The OSC target (BEYOND/FB4/routing) is a fact about the hardware next to
+ // this machine, not portable project state — a bundle exported from a laptop
+ // with no target must not wipe the one configured here.
+ let config = bundle.config;
+ let keptLocalOsc = false;
+ if (hasProject(paths, project) && config.osc == null) {
+ const localOsc = getProjectConfig(paths, project)?.osc;
+ if (localOsc && Object.keys(localOsc).length > 0) {
+ config = { ...config, osc: localOsc };
+ keptLocalOsc = true;
+ }
+ }
+
+ createProject(paths, project, config, { activate: opts.activate });
// Device-scoped configs travel; runtime facts (address/lastSeen) do not —
// each device re-registers with its own address when it next connects.
@@ -168,5 +183,5 @@ export function importProject(paths: StorePaths, bundle: PortableProject, opts:
generatedSecrets = true;
}
- return { project, generatedSecrets, deviceCount: devices.length, userCount: users.length };
+ return { project, generatedSecrets, deviceCount: devices.length, userCount: users.length, keptLocalOsc };
}
From 4e3885389225b76d415b7fbbc22b212193e0a993 Mon Sep 17 00:00:00 2001
From: Dan Lynch
Date: Mon, 21 Sep 2026 08:11:34 +0000
Subject: [PATCH 2/3] test(server): catalog rig filters include grace-28
---
packages/server/__tests__/catalog.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/server/__tests__/catalog.test.ts b/packages/server/__tests__/catalog.test.ts
index 2a10b65..381ba3c 100644
--- a/packages/server/__tests__/catalog.test.ts
+++ b/packages/server/__tests__/catalog.test.ts
@@ -51,7 +51,7 @@ describe('catalog', () => {
it('offers a filter per rig an operator builds shows for', () => {
const filters = layoutFilters();
- expect(filters.map(f => f.id)).toEqual(['all', 'grid-7x7', 'grace-cathedral', 'nova']);
+ expect(filters.map(f => f.id)).toEqual(['all', 'grid-7x7', 'grace-cathedral', 'grace-28', 'nova']);
expect(filters[0].layout).toBeNull();
expect(filters[3].layout?.count).toBe(6);
});
From 11386749278ebc4fd23f80e5cc1125ec203ab042 Mon Sep 17 00:00:00 2001
From: Dan Lynch
Date: Mon, 21 Sep 2026 08:12:07 +0000
Subject: [PATCH 3/3] test(server): nova filter index after grace-28
---
packages/server/__tests__/catalog.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/server/__tests__/catalog.test.ts b/packages/server/__tests__/catalog.test.ts
index 381ba3c..71df52a 100644
--- a/packages/server/__tests__/catalog.test.ts
+++ b/packages/server/__tests__/catalog.test.ts
@@ -53,7 +53,7 @@ describe('catalog', () => {
const filters = layoutFilters();
expect(filters.map(f => f.id)).toEqual(['all', 'grid-7x7', 'grace-cathedral', 'grace-28', 'nova']);
expect(filters[0].layout).toBeNull();
- expect(filters[3].layout?.count).toBe(6);
+ expect(filters[4].layout?.count).toBe(6);
});
});