Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/commands/project-io.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'));
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/renderer/routes/transfer-dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).'}
</p>
)}

Expand Down
2 changes: 2 additions & 0 deletions packages/desktop/src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
16 changes: 16 additions & 0 deletions packages/layout/__tests__/config-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion packages/layout/src/config-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ export function configEnvMap(config: WavegridConfig): Record<string, string> {
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);
Expand Down
15 changes: 13 additions & 2 deletions packages/layout/src/config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -45,14 +45,25 @@ 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.
*/
function envLayer(env: NodeJS.ProcessEnv): Partial<WavegridConfig> {
const out: Partial<WavegridConfig> = {};

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;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/server/__tests__/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
34 changes: 34 additions & 0 deletions packages/settings/__tests__/portable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
19 changes: 17 additions & 2 deletions packages/settings/src/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Empty osc: {} bundle wipes local OSC target

On overwrite, importProject preserves a machine's local OSC target only when config.osc == null (packages/settings/src/portable.ts:151). But the canonical "no target" form saved by wavegrid projects osc clear is config.osc = {} (packages/cli/src/commands/osc.ts:174), which exportProject ships verbatim, so an empty object fails the == null test and the branch is skipped.

The locally configured BEYOND/FB4/routing target is then silently overwritten with the bundle's empty block — exactly the cross-machine wipe this PR exists to prevent. Reuse the empty-object semantics already applied to the local side at line 153 on the bundle side.

📋 Prompt for AI Agents

In packages/settings/src/portable.ts at line 151, change the overwrite guard so an empty osc object is treated as "bundle had no target": replace config.osc == null with (config.osc == null || Object.keys(config.osc).length === 0). The local-keep branch at lines 152-157 then fires for a bundle whose config carries osc: {} (the shape persisted by wavegrid projects osc clear), preventing the import from wiping this machine's configured BEYOND/FB4/routing hardware target on overwrite. Short-circuiting keeps Object.keys safe for null/undefined, and any non-object osc yields zero keys, preserving the local target.

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.
Expand All @@ -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 };
}
Loading