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
13 changes: 12 additions & 1 deletion .agents/skills/wavegrid-simple-show/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,21 @@ wavegrid projects config # resolved config + provenance (secrets masked
wavegrid projects config set layout ring-6 # fix the physical layout
wavegrid projects config set port 3333 # change the port
wavegrid projects secrets list # which secrets exist (never prints values)
wavegrid projects users list # UI logins
wavegrid projects users list # UI logins (admin vs operator)
wavegrid projects guest new # mint ONE shared passphrase to hand out (printed once)
wavegrid doctor # diagnose everything (see below)
```

**Roles & shared guest access.** Every UI login has a role: the first user in a
project is an **admin** (manages users, roles, sessions, secrets); later ones
default to **operator** (drive the show only). For a "public password" everyone
can share, use **guest access** instead of a real account: `wavegrid projects
guest new` mints one shared passphrase — anyone who signs in with it becomes an
**operator**, never an admin. It's printed once (only a hash is stored); rotate
to invalidate it, `guest disable` to pause it, `guest rm` to remove it. In the
desktop app this lives under **Access → Guest access**. The shared receiver key
is unrelated and never grants admin.

The project **name is just a label** — the physical shape comes from `layout.preset`. If the canvas shows a grid when you expected a ring, set the preset.

## Doctor
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { runConfigSet } from './commands/config-set';
import { runDevicesAssign, runDevicesList, runDevicesRemove, runDevicesRename } from './commands/devices';
import { runDoctor } from './commands/doctor';
import { runEnvExport } from './commands/env';
import {
runGuestClear,
runGuestEnabled,
runGuestRotate,
runGuestStatus
} from './commands/guest';
import { runInit } from './commands/init';
import { pickCommand, pickSubcommand, printSubcommands, type SubCommand } from './commands/menu';
import { runOscSetup } from './commands/osc';
Expand Down Expand Up @@ -35,6 +41,7 @@ ${c.bold('Projects')} — manage and edit projects
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 users list|add|rm Manage UI login users
projects guest status|new Shared guest passphrase (one low-privilege operator)
projects devices list|assign List / name / shard-assign devices that joined
projects osc Set the OSC laser target (BEYOND/FB4) — wizard
projects export [--out f] Write a portable project bundle
Expand Down Expand Up @@ -82,6 +89,7 @@ const PROJECTS_SUBS: SubCommand[] = [
{ value: 'config', description: 'Print or set the project config' },
{ value: 'secrets', description: 'List or generate the project secrets' },
{ value: 'users', description: 'List, add, or remove UI login users' },
{ value: 'guest', description: 'Shared guest passphrase — one low-privilege operator to hand out' },
{ value: 'devices', description: 'List, rename, or forget devices that joined the project' },
{ value: 'osc', description: 'Set the OSC laser target (BEYOND/FB4/routing) — interactive wizard' },
{ value: 'export', description: 'Write a portable project bundle (no machine identity)' },
Expand Down Expand Up @@ -110,6 +118,14 @@ const USERS_SUBS: SubCommand[] = [
{ value: 'rm', description: 'Remove a UI login user' }
];

const GUEST_SUBS: SubCommand[] = [
{ value: 'status', description: 'Show whether shared guest access is set up / on' },
{ value: 'new', description: 'Mint a fresh shared passphrase (printed once)' },
{ value: 'enable', description: 'Turn shared guest logins on' },
{ value: 'disable', description: 'Turn shared guest logins off (passphrase kept)' },
{ value: 'rm', description: 'Remove shared guest access entirely' }
];

const DEVICES_SUBS: SubCommand[] = [
{ value: 'list', description: 'List devices that have joined the project' },
{ value: 'rename', description: 'Give a device a project-specific friendly name' },
Expand Down Expand Up @@ -170,6 +186,7 @@ const KNOWN_COMMANDS = [
'print-config',
'secrets',
'users',
'guest',
'devices',
'env',
'doctor'
Expand Down Expand Up @@ -251,6 +268,25 @@ async function dispatchUsers(
else unknownSub('users', sub);
}

async function dispatchGuest(
args: string[],
flags: Flags,
prompter: Inquirerer,
nonInteractive: boolean
): Promise<void> {
// Bare `guest` in a script prints status (safe, read-only default).
let given = args[0];
if (given == null && nonInteractive) given = 'status';
const sub = (await resolveSub(given, 'guest', GUEST_SUBS, prompter, nonInteractive)) ?? undefined;
if (sub == null) return;
if (sub === 'status') runGuestStatus(flags);
else if (sub === 'new' || sub === 'rotate') runGuestRotate(flags);
else if (sub === 'enable' || sub === 'on') runGuestEnabled(flags, true);
else if (sub === 'disable' || sub === 'off') runGuestEnabled(flags, false);
else if (sub === 'rm' || sub === 'remove' || sub === 'clear') runGuestClear(flags);
else unknownSub('guest', sub);
}

async function dispatchDevices(
args: string[],
flags: Flags,
Expand Down Expand Up @@ -319,6 +355,9 @@ async function dispatchProjects(
case 'users':
await dispatchUsers(rest, flags, prompter, nonInteractive);
break;
case 'guest':
await dispatchGuest(rest, flags, prompter, nonInteractive);
break;
case 'devices':
await dispatchDevices(rest, flags, prompter, nonInteractive);
break;
Expand Down Expand Up @@ -421,6 +460,9 @@ export async function run(argvInput: string[] = process.argv.slice(2)): Promise<
case 'users':
await dispatchUsers(positionals.slice(1), flags, prompter, nonInteractive);
break;
case 'guest':
await dispatchGuest(positionals.slice(1), flags, prompter, nonInteractive);
break;
case 'devices':
await dispatchDevices(positionals.slice(1), flags, prompter, nonInteractive);
break;
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/commands/guest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import c from 'yanse';

import { type Flags, getStore, resolveProjectName } from '../project';

/** `wavegrid guest status` — is shared guest access set up / on? */
export function runGuestStatus(flags: Flags): void {
const store = getStore();
const project = resolveProjectName(store, flags);
const guest = store.guestStatus(project);

console.log('');
console.log(c.bold(` Shared guest access · ${project}`));
if (!guest.configured) {
console.log(c.gray(' Not set up — create one with `wavegrid guest new`'));
} else {
console.log(` ${guest.enabled ? c.green('• enabled') : c.yellow('• disabled')}`);
console.log(c.gray(' Guests log in as operator (never admin) with the shared passphrase.'));
}
console.log('');
}

/**
* `wavegrid guest new` (alias `rotate`) — mint a fresh shared passphrase and
* print it once. Only its hash is stored; the old passphrase stops working.
*/
export function runGuestRotate(flags: Flags): void {
const store = getStore();
const project = resolveProjectName(store, flags);
const existed = store.guestStatus(project).configured;
const passphrase = store.rotateGuestPassphrase(project);

console.log('');
console.log(c.green(` ✓ ${existed ? 'Rotated' : 'Created'} the shared guest passphrase for ${project}`));
console.log('');
console.log(` ${c.bold(c.cyan(passphrase))}`);
console.log('');
console.log(c.gray(' Share this now — it is not shown again. Guests sign in as operator.'));
console.log('');
}

/** `wavegrid guest enable|disable` — flip logins without changing the passphrase. */
export function runGuestEnabled(flags: Flags, enabled: boolean): void {
const store = getStore();
const project = resolveProjectName(store, flags);
try {
const guest = store.setGuestEnabled(project, enabled);
console.log('');
console.log(c.green(` ✓ Shared guest access ${guest.enabled ? 'enabled' : 'disabled'} for ${project}`));
console.log('');
} catch (e) {
console.log('');
console.log(c.red(` ${(e as Error).message}`));
console.log('');
process.exitCode = 1;
}
}

/** `wavegrid guest rm` — remove shared guest access entirely. */
export function runGuestClear(flags: Flags): void {
const store = getStore();
const project = resolveProjectName(store, flags);
store.clearGuest(project);
console.log('');
console.log(c.green(` ✓ Removed shared guest access from ${project}`));
console.log('');
}
30 changes: 30 additions & 0 deletions packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ export function registerAllIpc(): void {
return store.hasProject(project) ? store.listSessions(project) : [];
});

ipcMain.handle('guest:status', (_e, project: string) => {
const store = openStore();
return store.hasProject(project)
? store.guestStatus(project)
: { configured: false, enabled: false, updatedAt: null };
});
ipcMain.handle('guest:rotate', (_e, project: string) => {
const store = openStore();
if (!store.hasProject(project)) {
return { passphrase: '', status: { configured: false, enabled: false, updatedAt: null } };
}
// The cleartext is returned exactly once for the admin to copy and share;
// only its scrypt hash is persisted. It is never logged.
const passphrase = store.rotateGuestPassphrase(project);
return { passphrase, status: store.guestStatus(project) };
});
ipcMain.handle('guest:setEnabled', (_e, project: string, enabled: boolean) => {
const store = openStore();
return store.hasProject(project)
? store.setGuestEnabled(project, enabled)
: { configured: false, enabled: false, updatedAt: null };
});
ipcMain.handle('guest:clear', (_e, project: string) => {
const store = openStore();
if (store.hasProject(project)) store.clearGuest(project);
return store.hasProject(project)
? store.guestStatus(project)
: { configured: false, enabled: false, updatedAt: null };
});

ipcMain.handle('secrets:status', (_e, project: string) => secretStatus(project));
ipcMain.handle('secrets:generate', (_e, project: string, force: boolean) => {
const store = openStore();
Expand Down
10 changes: 9 additions & 1 deletion packages/desktop/src/preload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';

import type { BrainStatus, DeviceInfo, EditableConfig, LaserSyncState, LightMapView, NewProjectInput, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc';
import type { BrainStatus, DeviceInfo, EditableConfig, GuestStatus, LaserSyncState, LightMapView, NewProjectInput, ProjectSummary, RequiredSecretInfo, SessionInfo, ShardRange, UserAccount, UserRole, WavegridApi, WavegridLaser } from '@/types/ipc';

// The single, narrow bridge exposed to the renderer. The renderer never imports
// @wavegrid/settings or `fs`; everything goes through these typed calls.
Expand Down Expand Up @@ -39,6 +39,14 @@ const api: WavegridApi = {
list: (project) => ipcRenderer.invoke('sessions:list', project) as Promise<SessionInfo[]>,
revoke: (project, id) => ipcRenderer.invoke('sessions:revoke', project, id) as Promise<SessionInfo[]>
},
guest: {
status: (project) => ipcRenderer.invoke('guest:status', project) as Promise<GuestStatus>,
rotate: (project) =>
ipcRenderer.invoke('guest:rotate', project) as Promise<{ passphrase: string; status: GuestStatus }>,
setEnabled: (project, enabled) =>
ipcRenderer.invoke('guest:setEnabled', project, enabled) as Promise<GuestStatus>,
clear: (project) => ipcRenderer.invoke('guest:clear', project) as Promise<GuestStatus>
},
secrets: {
status: (project) => ipcRenderer.invoke('secrets:status', project) as Promise<RequiredSecretInfo[]>,
generate: (project, force) =>
Expand Down
19 changes: 16 additions & 3 deletions packages/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConstructiveIcon } from '@/components/ui/constructive-icon';
import {
useBrainStatus,
useDevices,
useGuest,
useLightMap,
usePresets,
useProjectConfig,
Expand Down Expand Up @@ -75,6 +76,13 @@ export function App() {
refresh: refreshSessions,
revoke: revokeSession
} = useSessions(editingProject);
const {
guest,
refresh: refreshGuest,
rotate: rotateGuest,
setEnabled: setGuestEnabled,
clear: clearGuest
} = useGuest(editingProject);
const {
secrets,
refresh: refreshSecrets,
Expand Down Expand Up @@ -153,10 +161,10 @@ export function App() {
setRoute('config');
}, []);

const withBusy = React.useCallback(async (fn: () => Promise<void>) => {
const withBusy = React.useCallback(async <T,>(fn: () => Promise<T>): Promise<T> => {
setBusy(true);
try {
await fn();
return await fn();
} finally {
setBusy(false);
}
Expand Down Expand Up @@ -248,10 +256,11 @@ export function App() {
if (route === 'access') {
void refreshUsers();
void refreshSessions();
void refreshGuest();
void refreshSecrets();
}
if (route === 'lights') void refreshLightMap();
}, [route, refresh, refreshDevices, refreshConfig, refreshUsers, refreshSessions, refreshSecrets, refreshLightMap]);
}, [route, refresh, refreshDevices, refreshConfig, refreshUsers, refreshSessions, refreshGuest, refreshSecrets, refreshLightMap]);

return (
<AppShell
Expand Down Expand Up @@ -304,6 +313,10 @@ export function App() {
onSetUserRole={(u, r) => void withBusy(() => setUserRole(u, r))}
onRevokeSession={(id) => void withBusy(() => revokeSession(id))}
onRefreshSessions={() => void refreshSessions()}
guest={guest}
onRotateGuest={() => withBusy(() => rotateGuest())}
onSetGuestEnabled={(enabled) => void withBusy(() => setGuestEnabled(enabled))}
onClearGuest={() => void withBusy(() => clearGuest())}
onGenerateSecrets={(force) => void withBusy(() => generateSecrets(force))}
busy={busy}
/>
Expand Down
52 changes: 52 additions & 0 deletions packages/desktop/src/renderer/lib/use-wavegrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
BrainStatus,
DeviceInfo,
EditableConfig,
GuestStatus,
LightMapView,
NewProjectInput,
ProjectSummary,
Expand Down Expand Up @@ -213,6 +214,57 @@ export function useSessions(project: string | null): {
return { sessions, refresh, revoke };
}

/** Shared guest-access status + controls. Rotate mints a fresh passphrase and
* returns its cleartext once (for the admin to copy); the store keeps only a
* hash. Enabling/disabling flips logins on/off without changing it. */
export function useGuest(project: string | null): {
guest: GuestStatus;
refresh: () => Promise<void>;
rotate: () => Promise<string>;
setEnabled: (enabled: boolean) => Promise<void>;
clear: () => Promise<void>;
} {
const [guest, setGuest] = React.useState<GuestStatus>({
configured: false,
enabled: false,
updatedAt: null
});

const refresh = React.useCallback(async () => {
if (!project) {
setGuest({ configured: false, enabled: false, updatedAt: null });
return;
}
setGuest(await window.wavegrid.guest.status(project));
}, [project]);

const rotate = React.useCallback(async () => {
if (!project) return '';
const { passphrase, status } = await window.wavegrid.guest.rotate(project);
setGuest(status);
return passphrase;
}, [project]);

const setEnabled = React.useCallback(
async (enabled: boolean) => {
if (!project) return;
setGuest(await window.wavegrid.guest.setEnabled(project, enabled));
},
[project]
);

const clear = React.useCallback(async () => {
if (!project) return;
setGuest(await window.wavegrid.guest.clear(project));
}, [project]);

React.useEffect(() => {
void refresh();
}, [refresh]);

return { guest, refresh, rotate, setEnabled, clear };
}

/** Required-secret status for a project (name/description/set only). `generate`
* triggers one-time generation, or rotation with force=true. */
export function useProjectSecrets(project: string | null): {
Expand Down
Loading
Loading