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
12 changes: 12 additions & 0 deletions .agents/skills/testing-wavegrid-desktop-gui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ ELECTRON_ENABLE_LOGGING=1 DISPLAY=:0 \

- The CLI runs from built output, not a global binary: `node packages/cli/dist/bin.js …`
(`projects config`, `signals send|probe|listen`, `doctor`).
- If Forge reports "Electron failed to install correctly", pnpm may have skipped
Electron's install script. From `packages/desktop`, run
`node node_modules/electron/install.js`, then retry startup.
- For receiver-only GUI tests, run a separate real brain with
`WAVEGRID_PORT=3555 node packages/cli/dist/bin.js server`, using the same active
project as the desktop so receiver and embedded-UI secrets match. Join
`ws://127.0.0.1:3555` in Devices. Check that port 3000 is not listening in
receiver-only mode, then returns when Use local brain restarts the show.
- CLI subcommand `--help` may execute the command instead of displaying help;
avoid probing `server --help` while preparing port-sensitive tests.
- Receiver startup logs may include a `?key=` secret. Redact query key values
before sharing log artifacts.
- Store lives in `~/.wavegrid`; logs in `~/.wavegrid/logs/<project>/`.
- Set the layout explicitly or you may land in `distributed` run mode (49 cannons):
`node packages/cli/dist/bin.js projects config set layout nova` (6-cannon ring, simple mode).
Expand Down
14 changes: 14 additions & 0 deletions .agents/skills/wavegrid-distributed-show/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ wavegrid receiver # discovers the server via mDNS, connects
```
A bare `wavegrid receiver` also picks up the shard the operator assigned this laptop (`wavegrid devices assign`, below) — no `--shard` needed. Explicit override for multicast-blocked networks: `wavegrid receiver --server ws://192.168.1.42:3333 --shard 0-24` (an explicit `--shard` wins over the assigned one).

### Desktop app as a receiver (Join a brain)

On a receiver laptop, open Devices → Join a brain, paste the brain's `ws://`
or `wss://` URL (or use one found by scanning), choose Save, and then Start.
The CLI equivalent is `wavegrid projects config set receiver.server
wss://grace.hipzap.com` followed by `wavegrid receiver`; the `--server` flag
still overrides it. The receiver key must match the brain's project:
`wavegrid projects export --include-secrets` on the brain and import the bundle
(or use Desktop Projects → Export/Import), or run `wavegrid projects secrets set
receiverKey`. Clearing `receiver.server` returns to local-brain mode. Importing
without secrets means the embedded artist UI shows the brain's login screen
(the desktop signs it in with the project's `jwtSecret`); import with
`--include-secrets` to avoid it.

**At showtime:** operator paints → UI → server `broadcastCommand()` → every receiver filters to its shard → OSC to its hardware.

## Devices: identity, naming, management
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/__tests__/config-set.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,20 @@ describe('runConfigSet', () => {
getStore().createProject('p', { layout: { preset: 'ring-6' } });
await expect(runConfigSet('sync', 'maybe', {})).rejects.toThrow(/true or false/);
});

it('sets and clears a remote receiver brain', async () => {
isolate();
const store = getStore();
store.createProject('p', { layout: { preset: 'ring-6' } });
await runConfigSet('receiver.server', 'wss://grace.hipzap.com/path', {});
expect(store.getProjectConfig('p')?.receiver?.server).toBe('wss://grace.hipzap.com');
await runConfigSet('receiver.server', '', {});
expect(store.getProjectConfig('p')?.receiver).not.toHaveProperty('server');
});

it('rejects a non-ws receiver brain URL', async () => {
isolate();
getStore().createProject('p', { layout: { preset: 'ring-6' } });
await expect(runConfigSet('receiver.server', 'http://x', {})).rejects.toThrow(/ws:\/\//);
});
});
50 changes: 49 additions & 1 deletion packages/cli/__tests__/runtime-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'os';
import { join } from 'path';

import { applyAssignedShard, applyShardFlag, parseShardRange } from '../src/commands/runtime';
import { runReceiver } from '../src/commands/receiver';
import { resolveUpstream, runReceiver } from '../src/commands/receiver';
import { runServer } from '../src/commands/server';
import { buildConfig, CONFIG_FILENAME, serializeConfig } from '../src/config-file';

Expand Down Expand Up @@ -148,4 +148,52 @@ describe('runReceiver (dry-run)', () => {
await runReceiver({ cwd, dryRun: true, flags: { shard: '99-1' } });
expect(process.exitCode).toBe(1);
});

it('uses the configured brain when no flag is supplied', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'wg-rt-'));
const cfg = buildConfig({ shape: 'preset', preset: 'ring-6', mode: 'auto' });
cfg.receiver = { alpha: 0.06, fallbackDelay: 3000, server: 'wss://grace.hipzap.com' };
writeFileSync(join(cwd, CONFIG_FILENAME), serializeConfig(cfg));
const result = await runReceiver({ cwd, dryRun: true, flags: { discover: false } });
expect(result.server).toBe('wss://grace.hipzap.com');
});

it('lets an explicit flag override the configured brain', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'wg-rt-'));
const cfg = buildConfig({ shape: 'preset', preset: 'ring-6', mode: 'auto' });
cfg.receiver = { alpha: 0.06, fallbackDelay: 3000, server: 'wss://grace.hipzap.com' };
writeFileSync(join(cwd, CONFIG_FILENAME), serializeConfig(cfg));
const result = await runReceiver({
cwd,
dryRun: true,
flags: { discover: false, server: 'ws://127.0.0.1:3000' }
});
expect(result.server).toBe('ws://127.0.0.1:3000');
});
});

describe('resolveUpstream', () => {
it('prefers an explicit flag over the configured brain', async () => {
const discover = jest.fn(async () => 'ws://discovered:3000');
await expect(resolveUpstream('ws://flag:3000', 'ws://configured:3000', discover)).resolves.toBe('ws://flag:3000');
expect(discover).not.toHaveBeenCalled();
});

it('prefers the configured brain without discovery', async () => {
const discover = jest.fn(async () => 'ws://discovered:3000');
await expect(resolveUpstream(undefined, 'ws://configured:3000', discover)).resolves.toBe('ws://configured:3000');
expect(discover).not.toHaveBeenCalled();
});

it('uses the discovered brain when no flag or config is set', async () => {
const discover = jest.fn(async () => 'ws://discovered:3000');
await expect(resolveUpstream(undefined, undefined, discover)).resolves.toBe('ws://discovered:3000');
expect(discover).toHaveBeenCalledTimes(1);
});

it('returns undefined when no upstream is available', async () => {
const discover = jest.fn(async (): Promise<string | undefined> => undefined);
await expect(resolveUpstream(undefined, undefined, discover)).resolves.toBeUndefined();
expect(discover).toHaveBeenCalledTimes(1);
});
});
10 changes: 6 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
runRoutingImport,
runRoutingShow
} from './commands/routing';
import { runSecretsInit, runSecretsList } from './commands/secrets';
import { runSecretsInit, runSecretsList, runSecretsSet } from './commands/secrets';
import { runServer } from './commands/server';
import { runSettingsEnvironment, runSettingsInitialize } from './commands/settings';
import { runStart } from './commands/start';
Expand All @@ -48,7 +48,7 @@ ${c.bold('Projects')} — manage and edit projects
projects use <name> Set the active project
projects config Print the resolved config + provenance
projects config set <k> <v> Set a field (layout, mode, port, host, ui-port)
projects secrets list|init List / generate the project's secrets
projects secrets list|init|set List / generate / set the project's secrets
projects users list|add|rm Manage UI login users
projects keys ls|new|rm Named access keys (per-person or shared passphrases)
projects devices list|assign List / name / shard-assign devices that joined
Expand Down Expand Up @@ -129,12 +129,13 @@ const SETTINGS_SUBS: SubCommand[] = [

const CONFIG_SUBS: SubCommand[] = [
{ value: 'show', description: 'Print the resolved config + provenance (secrets masked)' },
{ value: 'set', description: 'Set a field: layout, mode, port, host, ui-port' }
{ value: 'set', description: 'Set a field: layout, mode, port, host, ui-port, receiver.server' }
];

const SECRETS_SUBS: SubCommand[] = [
{ value: 'list', description: 'List required secrets and whether each is set' },
{ value: 'init', description: 'Generate any missing secrets (--force to rotate)' }
{ value: 'init', description: 'Generate any missing secrets (--force to rotate)' },
{ value: 'set', description: 'Set a secret value (e.g. receiverKey from the brain’s project)' }
];

const USERS_SUBS: SubCommand[] = [
Expand Down Expand Up @@ -283,6 +284,7 @@ async function dispatchSecrets(
if (sub == null) return;
if (sub === 'init') runSecretsInit(flags);
else if (sub === 'list') runSecretsList(flags);
else if (sub === 'set') await runSecretsSet(args.slice(1), flags, nonInteractive ? undefined : prompter);
else unknownSub('secrets', sub);
}

Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/commands/config-set.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LAYOUT_SPEC_FORMS, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
import { DEFAULT_CONFIG, LAYOUT_SPEC_FORMS, parseBrainUrl, parseLayoutSpec, resolveLayout, type WavegridConfig } from '@wavegrid/layout';
import type { Inquirerer, Question } from 'inquirerer';
import c from 'yanse';

Expand Down Expand Up @@ -32,6 +32,12 @@ const SETTERS: Record<string, (config: Partial<WavegridConfig>, value: string) =
sync: (config, value) => {
const on = boolOrThrow('sync', value);
config.sync = { secrets: config.sync?.secrets ?? false, ...config.sync, enabled: on };
},
'receiver.server': (config, value) => {
const receiver = { ...DEFAULT_CONFIG.receiver, ...config.receiver };
if (value.trim() === '') delete receiver.server;
else receiver.server = parseBrainUrl(value);
config.receiver = receiver;
}
};

Expand All @@ -45,7 +51,8 @@ const KEY_CHOICES = [
{ value: 'port', description: 'Server port' },
{ value: 'host', description: 'Server host/bind address' },
{ value: 'ui-port', description: 'UI port' },
{ value: 'sync', description: 'Config sync across devices: true | false' }
{ value: 'sync', description: 'Config sync across devices: true | false' },
{ value: 'receiver.server', description: 'Remote brain this laptop’s receiver dials (ws:// or wss://); empty = local' }
];

function boolOrThrow(key: string, value: string): boolean {
Expand Down Expand Up @@ -130,7 +137,7 @@ export async function runConfigSet(
}

let resolvedValue = value;
if (resolvedValue == null || resolvedValue === '') {
if (resolvedValue == null || (resolvedValue === '' && resolvedKey !== 'receiver.server')) {
if (!prompter) {
console.log(c.red(` Missing value for "${resolvedKey}".`));
process.exitCode = 1;
Expand Down
62 changes: 38 additions & 24 deletions packages/cli/src/commands/receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
*
* wavegrid receiver --server ws://192.168.1.42:3333 --shard 0-24
*
* `--server` is the explicit upstream (required when the brain isn't this
* machine); `--shard start-end` restricts which cannons this laptop drives.
* Upstream precedence is `--server`, configured receiver.server, mDNS
* discovery, coordinator election, then localhost. An explicitly joined
* remote brain must not be hijacked by a stray brain on the LAN.
*/
import { browse, type DiscoveredBrain } from '@wavegrid/discovery';
import { loadWavegridConfig, type ResolvedConfig } from '@wavegrid/layout';
Expand All @@ -30,6 +31,17 @@ export function brainLabel(brain: DiscoveredBrain): string {
return `${brain.project} — ${where}:${brain.port}${brain.deviceName ? ` (${brain.deviceName})` : ''}`;
}

/** Upstream resolution order: --server flag, then the project's receiver.server, then a discovered brain. */
export async function resolveUpstream(
flag: string | undefined,
configured: string | undefined,
discover: () => Promise<string | undefined>
): Promise<string | undefined> {
if (flag) return flag;
if (configured) return configured;
return discover();
}

export interface ReceiverOptions {
cwd?: string;
/** Resolve + print the plan but do not start anything (tests). */
Expand Down Expand Up @@ -70,57 +82,58 @@ async function selectProject(opts: ReceiverOptions): Promise<string> {
export async function runReceiver(opts: ReceiverOptions = {}): Promise<ReceiverResult> {
const cwd = opts.cwd ?? process.cwd();
const flags = opts.flags ?? {};

// `--server ws://host:port` sets the upstream the receiver dials. Without it
// we try mDNS discovery, then (if nothing is found) hold a coordinator
// election, and only then fall back to the config/localhost default.
let serverFlag = typeof flags.server === 'string' ? flags.server : undefined;
const discover = flags.discover !== false && flags['no-discover'] !== true;
if (!serverFlag && !opts.dryRun && discover) {
const discovered = await discoverServer(opts);
if (discovered) serverFlag = discovered;
}
const resolved = loadWavegridConfig({ cwd });

if (!applyShardFlag(flags.shard)) {
console.log(c.red(`Invalid --shard: expected "start-end" (e.g. 0-24), got "${String(flags.shard)}"`));
process.exitCode = 1;
return { server: '', stop: () => {} };
}

// An explicit flag or configured remote takes precedence over discovery:
// an operator who joined a remote brain must not be hijacked by a stray LAN
// brain.
const serverFlag = await resolveUpstream(
typeof flags.server === 'string' ? flags.server : undefined,
resolved.config.receiver.server,
!opts.dryRun && flags.discover !== false && flags['no-discover'] !== true
? () => discoverServer(opts)
: async () => undefined
);
let resolvedServer = serverFlag;
const discover = flags.discover !== false && flags['no-discover'] !== true;

if (opts.dryRun) {
const resolved = loadWavegridConfig({ cwd });
printPlan(resolved, serverFlag);
return { server: serverFlag ?? process.env.SIMULATOR_URL ?? '', stop: () => {} };
printPlan(resolved, resolvedServer);
return { server: resolvedServer ?? process.env.SIMULATOR_URL ?? '', stop: () => {} };
}

const store = getStore();
const project = await selectProject(opts);

const resolved = loadWavegridConfig({ cwd });

// No brain on the LAN and this project replicates config across devices →
// elect a coordinator so sync still has an authority. The winner promotes
// itself to a transient brain (server + local receiver); everyone else homes
// to it. Simple/one-laptop projects skip this entirely.
if (!serverFlag && discover && p2pEligible(resolved, flags)) {
if (!resolvedServer && discover && p2pEligible(resolved, flags)) {
const device = store.getDevice();
console.log(c.gray(' No brain on the LAN — holding a coordinator election (mDNS)…'));
const result = await coordinate({ project, deviceId: device.id });
if (result.role === 'client' && result.server) {
console.log(` ${c.green('✓')} homing to elected brain ${c.cyan(result.server)}`);
serverFlag = result.server;
resolvedServer = result.server;
} else {
console.log(` ${c.green('▶')} ${c.bold('promoted to transient brain')} ${c.gray('— no server on the LAN; peers will connect here.')}`);
return promoteToBrain({ store, project, resolved });
}
}

if (serverFlag) process.env.SIMULATOR_URL = serverFlag;
if (resolvedServer) process.env.SIMULATOR_URL = resolvedServer;

// Wire env first (may set SHARD_START/END from this device's assigned shard)
// so the printed plan reflects the shard the receiver will actually drive.
applyReceiverEnv(store, project, resolved);
printPlan(resolved, serverFlag, project);
printPlan(resolved, resolvedServer, project);

const { startReceiver } = await import('@wavegrid/receiver');
const receiverHandle = startReceiver(resolved);
Expand Down Expand Up @@ -207,9 +220,10 @@ async function promoteToBrain(ctx: {

/**
* Browse the LAN for advertised brains. Returns a ws:// URL, or undefined to
* fall through to the config/localhost default. Prompts when several are found
* and a prompter is available; picks the only one automatically. Discovery is
* pure convenience — the connection still authenticates with the shared key.
* fall through to coordinator election or the config/localhost default.
* Prompts when several are found and a prompter is available; picks the only
* one automatically. Discovery is pure convenience — the connection still
* authenticates with the shared key.
*/
async function discoverServer(opts: ReceiverOptions): Promise<string | undefined> {
console.log(c.gray(' Searching the LAN for a Wavegrid brain (mDNS)…'));
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/commands/secrets.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SECRET_NAMES, type SecretName } from '@wavegrid/settings';
import type { Inquirerer } from 'inquirerer';
import c from 'yanse';

import { type Flags, getStore, resolveProjectName } from '../project';
Expand Down Expand Up @@ -49,3 +51,31 @@ export function runSecretsInit(flags: Flags): void {
}
console.log('');
}

export async function runSecretsSet(args: string[], flags: Flags, prompter?: Inquirerer): Promise<void> {
const name = args[0];
if (!name || !SECRET_NAMES.includes(name as SecretName)) {
console.log(c.red(` Unknown secret "${name ?? ''}". Valid names: ${SECRET_NAMES.join(', ')}`));
process.exitCode = 1;
return;
}
let value = args[1];
if (value == null && prompter) {
const answer = (await prompter.prompt({}, [{
type: 'password',
name: 'value',
message: `Value for ${name}`,
required: true
}])) as unknown as { value: unknown };
value = String(answer.value ?? '');
}
if (value == null || value.trim() === '') {
console.log(c.red(' Missing secret value.'));
process.exitCode = 1;
return;
}
const store = getStore();
const project = resolveProjectName(store, flags);
store.setSecret(project, name as SecretName, value);
console.log(` ${c.green('✓')} ${name} set · ${project}`);
}
4 changes: 2 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ export { buildEnvLines, runEnvExport } from './commands/env';
export { runInit } from './commands/init';
export { runPrintConfig } from './commands/print-config';
export { runProjects, runUse } from './commands/projects';
export { type ReceiverOptions, type ReceiverResult, runReceiver } from './commands/receiver';
export { type ReceiverOptions, type ReceiverResult, resolveUpstream, runReceiver } from './commands/receiver';
export { applyReceiverEnv, applyServerEnv, applyShardFlag, lanAddresses, resolveUiDir } from './commands/runtime';
export { runSecretsInit, runSecretsList } from './commands/secrets';
export { runSecretsInit, runSecretsList, runSecretsSet } from './commands/secrets';
export { runServer, type ServerOptions, type ServerResult } from './commands/server';
export { runStart, servicesForMode, type ServiceSpec, type StartOptions, type StartResult } from './commands/start';
export { runUsersAdd, runUsersList, runUsersRemove } from './commands/users';
Expand Down
14 changes: 14 additions & 0 deletions packages/desktop/__tests__/project-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ describe('buildLayoutSpec', () => {
});

describe('editable round-trip', () => {
it('sets and clears a remote receiver brain', () => {
const base = toEditable(null);
const stored = applyEditable(null, { ...base, receiverServer: 'wss://grace.hipzap.com/path' });
expect(stored.receiver?.server).toBe('wss://grace.hipzap.com');
expect(toEditable(stored).receiverServer).toBe('wss://grace.hipzap.com');
const cleared = applyEditable(stored, { ...toEditable(stored), receiverServer: '' });
expect(cleared.receiver).not.toHaveProperty('server');
});

it('rejects a non-ws receiver brain URL', () => {
expect(() => applyEditable(null, { ...toEditable(null), receiverServer: 'http://grace.hipzap.com' }))
.toThrow(/ws:\/\//);
});

it('keeps an annulus intact through the editor', () => {
const stored = applyEditable(null, {
...toEditable({ layout: { kind: 'annulus', count: 25, innerRadius: 0.5 } }),
Expand Down
Loading
Loading