@pellux/goodvibes-tui 0.19.27 → 0.19.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +3 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +3 -2
  6. package/src/cli/entrypoint.ts +2 -2
  7. package/src/cli/help.ts +1 -1
  8. package/src/cli/status.ts +9 -9
  9. package/src/cli/surface-command.ts +46 -11
  10. package/src/cli/tui-startup.ts +4 -4
  11. package/src/daemon/cli.ts +7 -0
  12. package/src/input/handler-interactions.ts +14 -1
  13. package/src/input/handler-onboarding.ts +161 -118
  14. package/src/input/handler.ts +1 -1
  15. package/src/input/onboarding/handler-onboarding-routes.ts +35 -15
  16. package/src/input/onboarding/onboarding-wizard-apply.ts +35 -25
  17. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -5
  18. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +93 -5
  19. package/src/input/onboarding/onboarding-wizard-helpers.ts +2 -3
  20. package/src/input/onboarding/onboarding-wizard-rules.ts +40 -8
  21. package/src/input/onboarding/onboarding-wizard-state.ts +19 -8
  22. package/src/input/onboarding/onboarding-wizard-steps.ts +226 -93
  23. package/src/input/onboarding/onboarding-wizard-types.ts +15 -0
  24. package/src/input/onboarding/onboarding-wizard.ts +123 -6
  25. package/src/input/settings-modal-types.ts +2 -1
  26. package/src/input/settings-modal.ts +4 -0
  27. package/src/main.ts +35 -27
  28. package/src/renderer/compositor.ts +3 -3
  29. package/src/renderer/onboarding/onboarding-wizard.ts +141 -57
  30. package/src/renderer/settings-modal-helpers.ts +9 -0
  31. package/src/renderer/settings-modal.ts +3 -0
  32. package/src/runtime/bootstrap.ts +15 -0
  33. package/src/runtime/onboarding/apply.ts +45 -90
  34. package/src/runtime/onboarding/derivation.ts +7 -7
  35. package/src/runtime/onboarding/markers.ts +41 -55
  36. package/src/runtime/onboarding/snapshot.ts +1 -0
  37. package/src/runtime/onboarding/state.ts +6 -6
  38. package/src/runtime/onboarding/types.ts +24 -27
  39. package/src/runtime/onboarding/verify.ts +3 -65
  40. package/src/runtime/surface-feature-flags.ts +67 -0
  41. package/src/version.ts +1 -1
@@ -17,19 +17,19 @@ export const DEFAULT_CAPABILITIES: readonly OnboardingStep1CapabilityItem[] = [
17
17
  id: 'local-tui-only',
18
18
  label: 'Local TUI Only (No Servers)',
19
19
  selected: true,
20
- detail: 'Keep GoodVibes in this terminal and disable browser access, background services, network listeners, and external surfaces.',
20
+ detail: 'Use GoodVibes only in this terminal. No browser access, background service, HTTP listener, external app surface, or network setup.',
21
21
  },
22
22
  {
23
23
  id: 'browser-access',
24
24
  label: 'Open GoodVibes in a Browser',
25
25
  selected: false,
26
- detail: 'Enable the background service and web UI, reachable on the local network by default unless customized.',
26
+ detail: 'Run the background service and web UI. GoodVibes will use the local network by default; you can restrict or customize it next.',
27
27
  },
28
28
  {
29
29
  id: 'network-access',
30
30
  label: 'Let other devices use GoodVibes',
31
31
  selected: false,
32
- detail: 'Expose enabled GoodVibes services on your LAN so other devices can reach them. Local auth is required.',
32
+ detail: 'Make enabled GoodVibes services reachable from other devices on your LAN. Local authentication is required.',
33
33
  },
34
34
  {
35
35
  id: 'webhook-events',
@@ -41,7 +41,7 @@ export const DEFAULT_CAPABILITIES: readonly OnboardingStep1CapabilityItem[] = [
41
41
  id: 'external-integrations',
42
42
  label: 'Connect GoodVibes to external apps and services',
43
43
  selected: false,
44
- detail: 'Show Slack, Discord, Telegram, Teams, Matrix, and other app surfaces so they can be enabled and configured here.',
44
+ detail: 'Enable setup screens for Slack, Discord, Telegram, Teams, Matrix, and other app surfaces you choose.',
45
45
  },
46
46
  ];
47
47
 
@@ -115,7 +115,6 @@ export const REQUIRED_EXTERNAL_SETUP_FIELD_IDS = new Set<string>([
115
115
  'external-services.discord.public-key',
116
116
  'external-services.telegram.bot-token',
117
117
  'external-services.telegram.webhook-secret',
118
- 'external-services.ntfy.topic',
119
118
  'external-services.webhook.default-target',
120
119
  'external-services.google-chat.webhook-url',
121
120
  'external-services.google-chat.verification-token',
@@ -1,4 +1,10 @@
1
- import type { ConfigKey } from '../../config/index.ts';
1
+ import {
2
+ GOODVIBES_NTFY_AGENT_TOPIC,
3
+ GOODVIBES_NTFY_CHAT_TOPIC,
4
+ GOODVIBES_NTFY_REMOTE_TOPIC,
5
+ resolveGoodVibesNtfyTopics,
6
+ } from '@pellux/goodvibes-sdk/platform/integrations/ntfy';
7
+ import { DEFAULT_CONFIG, type ConfigKey } from '../../config/index.ts';
2
8
  import type { OnboardingSnapshotState } from '../../runtime/onboarding/index.ts';
3
9
  import { TELEGRAM_MODE_OPTIONS, WHATSAPP_PROVIDER_OPTIONS } from './onboarding-wizard-constants.ts';
4
10
  import type { OnboardingWizardRadioOption } from './onboarding-wizard-types.ts';
@@ -24,10 +30,65 @@ export interface ExternalSurfaceSpec {
24
30
  readonly enabledConfigKey: ConfigKey;
25
31
  readonly label: string;
26
32
  readonly hint: string;
33
+ /**
34
+ * Existing SDK config key. In onboarding this maps to the per-surface
35
+ * auto-start choice, not to whether setup fields are shown.
36
+ */
27
37
  readonly defaultEnabled: (snapshot: OnboardingSnapshotState | null) => boolean;
28
38
  readonly fields: readonly ExternalSurfaceSetupFieldSpec[];
29
39
  }
30
40
 
41
+ function normalizeConfigValue(value: unknown): string {
42
+ if (value === null || value === undefined) return '';
43
+ return String(value).trim();
44
+ }
45
+
46
+ function getDefaultConfigValue(key: ConfigKey): unknown {
47
+ return key.split('.').reduce<unknown>((cursor, part) => (
48
+ typeof cursor === 'object' && cursor !== null && part in cursor
49
+ ? (cursor as Record<string, unknown>)[part]
50
+ : undefined
51
+ ), DEFAULT_CONFIG);
52
+ }
53
+
54
+ export function getExternalSurfaceAutoStartFieldId(surface: ExternalSurfaceSpec): string {
55
+ return `${surface.enabledFieldId}.auto-start`;
56
+ }
57
+
58
+ export function getExternalSurfaceAutoStartDefaultValue(
59
+ surface: ExternalSurfaceSpec,
60
+ snapshot: OnboardingSnapshotState | null,
61
+ ): 'yes' | 'no' {
62
+ return surface.defaultEnabled(snapshot) ? 'yes' : 'no';
63
+ }
64
+
65
+ export function isExternalSurfaceSelectedByDefault(
66
+ surface: ExternalSurfaceSpec,
67
+ snapshot: OnboardingSnapshotState | null,
68
+ ): boolean {
69
+ if (surface.defaultEnabled(snapshot)) return true;
70
+ if (!snapshot) return false;
71
+
72
+ return surface.fields.some((field) => {
73
+ const current = normalizeConfigValue(field.defaultValue(snapshot));
74
+ const defaultValue = normalizeConfigValue(getDefaultConfigValue(field.configKey));
75
+ return current.length > 0 && current !== defaultValue;
76
+ });
77
+ }
78
+
79
+ export function getNtfyProtocolTopicLines(snapshot: OnboardingSnapshotState | null): readonly string[] {
80
+ const topics = resolveGoodVibesNtfyTopics({
81
+ chatTopic: snapshot?.config.surfaces.ntfy.chatTopic,
82
+ agentTopic: snapshot?.config.surfaces.ntfy.agentTopic,
83
+ remoteTopic: snapshot?.config.surfaces.ntfy.remoteTopic,
84
+ });
85
+ return [
86
+ `Chat topic: ${topics.chatTopic}`,
87
+ `Agent topic: ${topics.agentTopic}`,
88
+ `Daemon-only remote topic: ${topics.remoteTopic}`,
89
+ ];
90
+ }
91
+
31
92
  export const EXTERNAL_SURFACE_SPECS: readonly ExternalSurfaceSpec[] = [
32
93
  {
33
94
  id: 'slack',
@@ -200,7 +261,7 @@ export const EXTERNAL_SURFACE_SPECS: readonly ExternalSurfaceSpec[] = [
200
261
  enabledFieldId: 'external-services.ntfy',
201
262
  enabledConfigKey: 'surfaces.ntfy.enabled',
202
263
  label: 'ntfy surface',
203
- hint: 'Enable ntfy notifications for lightweight device alerts.',
264
+ hint: 'Configure ntfy chat, agent, remote-session, and notification delivery topics.',
204
265
  defaultEnabled: (snapshot) => snapshot?.config.surfaces.ntfy.enabled ?? false,
205
266
  fields: [
206
267
  {
@@ -212,12 +273,39 @@ export const EXTERNAL_SURFACE_SPECS: readonly ExternalSurfaceSpec[] = [
212
273
  placeholder: 'https://ntfy.sh',
213
274
  defaultValue: (snapshot) => snapshot?.config.surfaces.ntfy.baseUrl ?? 'https://ntfy.sh',
214
275
  },
276
+ {
277
+ id: 'external-services.ntfy.chat-topic',
278
+ configKey: 'surfaces.ntfy.chatTopic',
279
+ kind: 'text',
280
+ label: 'ntfy chat topic',
281
+ hint: 'Messages sent here attach to the active terminal TUI session and reply back to ntfy.',
282
+ placeholder: GOODVIBES_NTFY_CHAT_TOPIC,
283
+ defaultValue: (snapshot) => snapshot?.config.surfaces.ntfy.chatTopic ?? GOODVIBES_NTFY_CHAT_TOPIC,
284
+ },
285
+ {
286
+ id: 'external-services.ntfy.agent-topic',
287
+ configKey: 'surfaces.ntfy.agentTopic',
288
+ kind: 'text',
289
+ label: 'ntfy agent topic',
290
+ hint: 'Messages sent here start agent work attached to the active TUI session.',
291
+ placeholder: GOODVIBES_NTFY_AGENT_TOPIC,
292
+ defaultValue: (snapshot) => snapshot?.config.surfaces.ntfy.agentTopic ?? GOODVIBES_NTFY_AGENT_TOPIC,
293
+ },
294
+ {
295
+ id: 'external-services.ntfy.remote-topic',
296
+ configKey: 'surfaces.ntfy.remoteTopic',
297
+ kind: 'text',
298
+ label: 'ntfy daemon-only remote topic',
299
+ hint: 'Messages sent here start an ntfy remote session in the daemon and do not appear in the TUI.',
300
+ placeholder: GOODVIBES_NTFY_REMOTE_TOPIC,
301
+ defaultValue: (snapshot) => snapshot?.config.surfaces.ntfy.remoteTopic ?? GOODVIBES_NTFY_REMOTE_TOPIC,
302
+ },
215
303
  {
216
304
  id: 'external-services.ntfy.topic',
217
305
  configKey: 'surfaces.ntfy.topic',
218
306
  kind: 'text',
219
- label: 'ntfy topic',
220
- hint: 'Default ntfy topic for notifications.',
307
+ label: 'ntfy default delivery topic',
308
+ hint: 'Optional outbound notification topic. It does not control chat, agent, or daemon-only remote routing.',
221
309
  placeholder: 'goodvibes',
222
310
  defaultValue: (snapshot) => snapshot?.config.surfaces.ntfy.topic ?? '',
223
311
  },
@@ -227,7 +315,7 @@ export const EXTERNAL_SURFACE_SPECS: readonly ExternalSurfaceSpec[] = [
227
315
  kind: 'masked',
228
316
  label: 'ntfy token',
229
317
  hint: 'Optional token for authenticated ntfy servers.',
230
- placeholder: 'token',
318
+ placeholder: 'empty for anonymous ntfy',
231
319
  defaultValue: (snapshot) => snapshot?.config.surfaces.ntfy.token ?? '',
232
320
  },
233
321
  {
@@ -209,10 +209,9 @@ export function getRuntimeDerivedState(hydration: OnboardingWizardRuntimeHydrati
209
209
  }
210
210
 
211
211
  export function getOnboardingWizardBodyRows(viewportHeight: number): number {
212
- return Math.max(5, viewportHeight - 5);
212
+ return Math.max(6, viewportHeight - 5);
213
213
  }
214
214
 
215
215
  export function getOnboardingWizardVisibleFieldCount(viewportHeight: number): number {
216
- return Math.max(1, Math.floor((getOnboardingWizardBodyRows(viewportHeight) - 5) / 2));
216
+ return Math.max(1, getOnboardingWizardBodyRows(viewportHeight) - 6);
217
217
  }
218
-
@@ -1,6 +1,11 @@
1
1
  import type { OnboardingStep1CapabilityId } from '../../runtime/onboarding/index.ts';
2
2
  import { INBOUND_EXTERNAL_SURFACE_IDS, REQUIRED_EXTERNAL_SETUP_FIELD_IDS } from './onboarding-wizard-constants.ts';
3
- import { EXTERNAL_SURFACE_SPECS } from './onboarding-wizard-external-surfaces.ts';
3
+ import {
4
+ EXTERNAL_SURFACE_SPECS,
5
+ getExternalSurfaceAutoStartDefaultValue,
6
+ getExternalSurfaceAutoStartFieldId,
7
+ isExternalSurfaceSelectedByDefault,
8
+ } from './onboarding-wizard-external-surfaces.ts';
4
9
  import { getExternalSurfaceSpecByFieldId, normalizeText, uniqueNonEmpty } from './onboarding-wizard-helpers.ts';
5
10
  import type { OnboardingWizardController } from './onboarding-wizard.ts';
6
11
 
@@ -30,10 +35,6 @@ export function getSharedIpHostDefault(
30
35
  return hosts[0] ?? '0.0.0.0';
31
36
  }
32
37
 
33
- export function defaultReviewUserMarker(controller: OnboardingWizardController): boolean {
34
- return controller.mode === 'new';
35
- }
36
-
37
38
  export function toggleCapability(controller: OnboardingWizardController, capabilityId: OnboardingStep1CapabilityId): void {
38
39
  if (capabilityId === 'local-tui-only') {
39
40
  for (const capability of controller.getCurrentCapabilities()) {
@@ -69,6 +70,18 @@ export function selectLocalTuiOnly(controller: OnboardingWizardController): void
69
70
  }
70
71
  }
71
72
 
73
+ export function selectAllExternalSurfaces(controller: OnboardingWizardController): void {
74
+ for (const surface of EXTERNAL_SURFACE_SPECS) {
75
+ controller.toggleState.set(surface.enabledFieldId, true);
76
+ }
77
+ }
78
+
79
+ export function clearExternalSurfaces(controller: OnboardingWizardController): void {
80
+ for (const surface of EXTERNAL_SURFACE_SPECS) {
81
+ controller.toggleState.set(surface.enabledFieldId, false);
82
+ }
83
+ }
84
+
72
85
  export function setCapabilityValue(controller: OnboardingWizardController, capabilityId: OnboardingStep1CapabilityId, selected: boolean): void {
73
86
  if (capabilityId === 'local-tui-only') {
74
87
  if (selected) {
@@ -116,7 +129,14 @@ export function hasSelectedInboundExternalSurface(controller: OnboardingWizardCo
116
129
  if (!controller.isCapabilitySelected('external-integrations')) return false;
117
130
  return EXTERNAL_SURFACE_SPECS.some((surface) => (
118
131
  INBOUND_EXTERNAL_SURFACE_IDS.has(surface.id)
119
- && controller.getBooleanFieldValue(surface.enabledFieldId, surface.defaultEnabled(controller.runtimeSnapshot))
132
+ && controller.getBooleanFieldValue(
133
+ surface.enabledFieldId,
134
+ isExternalSurfaceSelectedByDefault(surface, controller.runtimeSnapshot),
135
+ )
136
+ && controller.getStringFieldValue(
137
+ getExternalSurfaceAutoStartFieldId(surface),
138
+ getExternalSurfaceAutoStartDefaultValue(surface, controller.runtimeSnapshot),
139
+ ) === 'yes'
120
140
  ));
121
141
  }
122
142
 
@@ -125,7 +145,10 @@ export function isRequiredExternalSetupField(controller: OnboardingWizardControl
125
145
  const surface = getExternalSurfaceSpecByFieldId(fieldId);
126
146
  if (!surface) return false;
127
147
  if (!controller.isCapabilitySelected('external-integrations')
128
- || !controller.getBooleanFieldValue(surface.enabledFieldId, surface.defaultEnabled(controller.runtimeSnapshot))) {
148
+ || !controller.getBooleanFieldValue(
149
+ surface.enabledFieldId,
150
+ isExternalSurfaceSelectedByDefault(surface, controller.runtimeSnapshot),
151
+ )) {
129
152
  return false;
130
153
  }
131
154
  if (fieldId === 'external-services.telegram.webhook-secret') {
@@ -172,7 +195,7 @@ export function shouldExposeControlPlaneNetwork(controller: OnboardingWizardCont
172
195
 
173
196
  export function requiresAuthBootstrap(controller: OnboardingWizardController): boolean {
174
197
  return controller.hasServerCapabilitiesSelected()
175
- && (!controller.hasAdminAuthUser() || controller.hasBootstrapCredentialPresent());
198
+ && (!controller.hasLocalAuthUser() || controller.hasBootstrapCredentialPresent());
176
199
  }
177
200
 
178
201
  export function hasAdminAuthUser(controller: OnboardingWizardController): boolean {
@@ -180,12 +203,21 @@ export function hasAdminAuthUser(controller: OnboardingWizardController): boolea
180
203
  .some((user) => user.roles.includes('admin'));
181
204
  }
182
205
 
206
+ export function hasLocalAuthUser(controller: OnboardingWizardController): boolean {
207
+ return (controller.runtimeSnapshot?.auth.snapshot.userCount ?? 0) > 0
208
+ || (controller.runtimeSnapshot?.auth.snapshot.users ?? []).length > 0;
209
+ }
210
+
183
211
  export function hasBootstrapCredentialPresent(controller: OnboardingWizardController): boolean {
184
212
  return controller.runtimeSnapshot?.auth.snapshot.bootstrapCredentialPresent === true;
185
213
  }
186
214
 
187
215
  export function getDefaultAdminUsername(controller: OnboardingWizardController): string {
188
216
  const users = controller.runtimeSnapshot?.auth.snapshot.users ?? [];
217
+ if (controller.hasBootstrapCredentialPresent()) {
218
+ const existingAdmin = users.find((user) => user.roles.includes('admin'));
219
+ if (existingAdmin) return existingAdmin.username;
220
+ }
189
221
  const candidates = controller.hasBootstrapCredentialPresent()
190
222
  ? ['goodvibes-admin', 'admin']
191
223
  : ['admin', 'goodvibes-admin'];
@@ -72,21 +72,26 @@ export function getFieldValidationError(
72
72
  if (field.kind !== 'text' && field.kind !== 'masked') return null;
73
73
 
74
74
  const value = normalizeText(controller.getFieldValue(field) as string);
75
- const required = field.required === true || controller.isRequiredExternalSetupField(field.id);
75
+ const required = field.required === true;
76
76
  if (required && value.length === 0) {
77
77
  return `${step.shortLabel}: ${field.label} is required.`;
78
78
  }
79
79
 
80
80
  if (field.id === 'accounts.admin-username') {
81
- const existing = controller.runtimeSnapshot?.auth.snapshot.users.find((user) => user.username === value);
82
- if (controller.hasBootstrapCredentialPresent() && existing) {
83
- return `${step.shortLabel}: ${field.label} must be a new username so the wizard can replace bootstrap credentials.`;
81
+ const password = normalizeText(controller.getStringFieldValue('accounts.admin-password', ''));
82
+ if (!required && password.length > 0 && value.length === 0) {
83
+ return `${step.shortLabel}: ${field.label} is required when setting a local auth password.`;
84
84
  }
85
- if (existing && !existing.roles.includes('admin')) {
86
- return `${step.shortLabel}: ${field.label} must be a new username or an existing admin user.`;
85
+ const existing = controller.runtimeSnapshot?.auth.snapshot.users.find((user) => user.username === value);
86
+ if ((required || password.length > 0) && existing && !existing.roles.includes('admin')) {
87
+ return `${step.shortLabel}: ${field.label} must be an existing admin user or a new username.`;
87
88
  }
88
89
  }
89
90
 
91
+ if (field.id === 'accounts.admin-password' && value.length > 0 && value.length < 8) {
92
+ return `${step.shortLabel}: ${field.label} must be at least 8 characters.`;
93
+ }
94
+
90
95
  if (field.kind === 'masked' && isMalformedGoodVibesSecretReferenceValue(value)) {
91
96
  return `${step.shortLabel}: ${field.label} must be a secret value or a goodvibes://secrets/... reference.`;
92
97
  }
@@ -313,14 +318,20 @@ export function isFieldDirtyByDefinition(controller: OnboardingWizardController,
313
318
  }
314
319
 
315
320
  export function isFieldSatisfied(controller: OnboardingWizardController, field: OnboardingWizardFieldDefinition): boolean {
316
- if (field.kind === 'checklist' || field.kind === 'acknowledgement') {
317
- if (field.kind === 'acknowledgement' && !field.required) return true;
321
+ if (field.kind === 'checklist') {
322
+ return true;
323
+ }
324
+
325
+ if (field.kind === 'acknowledgement') {
326
+ if (!field.required) return true;
318
327
  return Boolean(controller.getFieldValue(field));
319
328
  }
320
329
 
321
330
  if (field.kind === 'radio') return true;
322
331
 
323
332
  if (field.kind === 'text' || field.kind === 'masked') {
333
+ const required = field.required === true;
334
+ if (!required) return true;
324
335
  return normalizeText(controller.getFieldValue(field) as string).length > 0;
325
336
  }
326
337