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/server/__tests__/catalog.test.ts b/packages/server/__tests__/catalog.test.ts index 2a10b65..71df52a 100644 --- a/packages/server/__tests__/catalog.test.ts +++ b/packages/server/__tests__/catalog.test.ts @@ -51,9 +51,9 @@ 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); + expect(filters[4].layout?.count).toBe(6); }); }); 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 }; }