@pellux/goodvibes-tui 0.19.28 → 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 (32) hide show
  1. package/CHANGELOG.md +6 -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/surface-command.ts +46 -11
  6. package/src/daemon/cli.ts +7 -0
  7. package/src/input/handler-onboarding.ts +151 -44
  8. package/src/input/onboarding/handler-onboarding-routes.ts +4 -0
  9. package/src/input/onboarding/onboarding-wizard-apply.ts +35 -8
  10. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -5
  11. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +93 -5
  12. package/src/input/onboarding/onboarding-wizard-helpers.ts +2 -2
  13. package/src/input/onboarding/onboarding-wizard-rules.ts +22 -3
  14. package/src/input/onboarding/onboarding-wizard-state.ts +12 -7
  15. package/src/input/onboarding/onboarding-wizard-steps.ts +133 -59
  16. package/src/input/onboarding/onboarding-wizard-types.ts +10 -0
  17. package/src/input/onboarding/onboarding-wizard.ts +56 -4
  18. package/src/input/settings-modal-types.ts +2 -1
  19. package/src/input/settings-modal.ts +4 -0
  20. package/src/main.ts +33 -26
  21. package/src/renderer/compositor.ts +3 -3
  22. package/src/renderer/onboarding/onboarding-wizard.ts +38 -21
  23. package/src/renderer/settings-modal-helpers.ts +9 -0
  24. package/src/renderer/settings-modal.ts +3 -0
  25. package/src/runtime/bootstrap.ts +15 -0
  26. package/src/runtime/onboarding/apply.ts +36 -8
  27. package/src/runtime/onboarding/derivation.ts +7 -7
  28. package/src/runtime/onboarding/snapshot.ts +1 -0
  29. package/src/runtime/onboarding/types.ts +4 -1
  30. package/src/runtime/onboarding/verify.ts +1 -1
  31. package/src/runtime/surface-feature-flags.ts +67 -0
  32. package/src/version.ts +1 -1
@@ -1,5 +1,11 @@
1
1
  import type { OnboardingAcknowledgementTarget, OnboardingApplyOperation, OnboardingApplyRequest } from '../../runtime/onboarding/index.ts';
2
- import { EXTERNAL_SURFACE_SPECS } from './onboarding-wizard-external-surfaces.ts';
2
+ import { getServerSurfaceFeatureFlags } from '../../runtime/surface-feature-flags.ts';
3
+ import {
4
+ EXTERNAL_SURFACE_SPECS,
5
+ getExternalSurfaceAutoStartDefaultValue,
6
+ getExternalSurfaceAutoStartFieldId,
7
+ isExternalSurfaceSelectedByDefault,
8
+ } from './onboarding-wizard-external-surfaces.ts';
3
9
  import { buildGoodVibesSecretKey, buildGoodVibesSecretRef, isLoopbackAddress, isSecretReferenceValue } from './onboarding-wizard-helpers.ts';
4
10
  import type { OnboardingWizardController } from './onboarding-wizard.ts';
5
11
 
@@ -8,6 +14,7 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
8
14
  const hasServers = controller.hasServerCapabilitiesSelected();
9
15
  const browserAccess = controller.shouldEnableBrowserSurface();
10
16
  const httpListener = controller.shouldEnableHttpListener();
17
+ const httpListenerNetworkFields = controller.shouldExposeHttpListenerNetworkFields();
11
18
  const controlPlaneRemote = controller.shouldExposeControlPlaneNetwork();
12
19
  const networkMode = controller.getStringFieldValue('network.mode', controller.runtimeDerived.step1_5NetworkMode);
13
20
  const customNetwork = hasServers && networkMode === 'custom';
@@ -18,6 +25,9 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
18
25
  ): void => {
19
26
  operations.push({ kind: 'set-config', key, value });
20
27
  };
28
+ const enableFeatureFlags = (flagIds: readonly string[]): void => {
29
+ for (const flagId of flagIds) setConfig(`featureFlags.${flagId}`, 'enabled');
30
+ };
21
31
  const acknowledge = (target: OnboardingAcknowledgementTarget, fieldId: string): void => {
22
32
  operations.push({
23
33
  kind: 'acknowledge',
@@ -50,11 +60,13 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
50
60
  setConfig(key, buildGoodVibesSecretRef(secretKey));
51
61
  };
52
62
 
53
- if (controller.requiresAuthBootstrap()) {
63
+ const requestedAdminPassword = controller.getStringFieldValue('accounts.admin-password', '');
64
+ const shouldEnsureAuthUser = controller.requiresAuthBootstrap() || requestedAdminPassword.length > 0;
65
+ if (shouldEnsureAuthUser) {
54
66
  operations.push({
55
67
  kind: 'ensure-auth-user',
56
68
  username: controller.getStringFieldValue('accounts.admin-username', controller.getDefaultAdminUsername()),
57
- password: controller.getStringFieldValue('accounts.admin-password', ''),
69
+ password: requestedAdminPassword,
58
70
  roles: ['admin'],
59
71
  createSession: true,
60
72
  retireBootstrapCredential: controller.hasBootstrapCredentialPresent(),
@@ -73,7 +85,7 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
73
85
  addNetworkOperations(controller, operations, customNetwork, {
74
86
  controlPlane: hasServers,
75
87
  controlPlaneRemote,
76
- httpListener,
88
+ httpListener: httpListenerNetworkFields,
77
89
  web: browserAccess,
78
90
  });
79
91
  } else {
@@ -100,11 +112,21 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
100
112
  setSecret('OPENAI_API_KEY', controller.getStringFieldValue('providers.openai-api-key', ''));
101
113
 
102
114
  const externalIntegrations = controller.isCapabilitySelected('external-integrations');
115
+ const enabledExternalSurfaceIds: string[] = [];
103
116
  for (const surface of EXTERNAL_SURFACE_SPECS) {
104
- const enabled = externalIntegrations
105
- && controller.getBooleanFieldValue(surface.enabledFieldId, surface.defaultEnabled(controller.runtimeSnapshot));
106
- setConfig(surface.enabledConfigKey, enabled);
107
- if (!enabled) continue;
117
+ const selected = externalIntegrations
118
+ && controller.getBooleanFieldValue(
119
+ surface.enabledFieldId,
120
+ isExternalSurfaceSelectedByDefault(surface, controller.runtimeSnapshot),
121
+ );
122
+ const autoStart = selected
123
+ && controller.getStringFieldValue(
124
+ getExternalSurfaceAutoStartFieldId(surface),
125
+ getExternalSurfaceAutoStartDefaultValue(surface, controller.runtimeSnapshot),
126
+ ) === 'yes';
127
+ setConfig(surface.enabledConfigKey, autoStart);
128
+ if (!selected) continue;
129
+ enabledExternalSurfaceIds.push(surface.id);
108
130
 
109
131
  for (const setupField of surface.fields) {
110
132
  const fallback = setupField.defaultValue(controller.runtimeSnapshot);
@@ -126,6 +148,11 @@ export function buildOnboardingApplyRequest(controller: OnboardingWizardControll
126
148
  else setConfig(setupField.configKey, value);
127
149
  }
128
150
  }
151
+ enableFeatureFlags(getServerSurfaceFeatureFlags({
152
+ serverBacked: hasServers,
153
+ web: browserAccess,
154
+ externalSurfaces: enabledExternalSurfaceIds,
155
+ }));
129
156
 
130
157
  acknowledge('providers', 'providers.reviewed');
131
158
  acknowledge('subscriptions', 'accounts.subscriptions');
@@ -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,9 +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, getOnboardingWizardBodyRows(viewportHeight) - 5);
216
+ return Math.max(1, getOnboardingWizardBodyRows(viewportHeight) - 6);
217
217
  }
@@ -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
 
@@ -124,7 +129,14 @@ export function hasSelectedInboundExternalSurface(controller: OnboardingWizardCo
124
129
  if (!controller.isCapabilitySelected('external-integrations')) return false;
125
130
  return EXTERNAL_SURFACE_SPECS.some((surface) => (
126
131
  INBOUND_EXTERNAL_SURFACE_IDS.has(surface.id)
127
- && 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'
128
140
  ));
129
141
  }
130
142
 
@@ -133,7 +145,10 @@ export function isRequiredExternalSetupField(controller: OnboardingWizardControl
133
145
  const surface = getExternalSurfaceSpecByFieldId(fieldId);
134
146
  if (!surface) return false;
135
147
  if (!controller.isCapabilitySelected('external-integrations')
136
- || !controller.getBooleanFieldValue(surface.enabledFieldId, surface.defaultEnabled(controller.runtimeSnapshot))) {
148
+ || !controller.getBooleanFieldValue(
149
+ surface.enabledFieldId,
150
+ isExternalSurfaceSelectedByDefault(surface, controller.runtimeSnapshot),
151
+ )) {
137
152
  return false;
138
153
  }
139
154
  if (fieldId === 'external-services.telegram.webhook-secret') {
@@ -199,6 +214,10 @@ export function hasBootstrapCredentialPresent(controller: OnboardingWizardContro
199
214
 
200
215
  export function getDefaultAdminUsername(controller: OnboardingWizardController): string {
201
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
+ }
202
221
  const candidates = controller.hasBootstrapCredentialPresent()
203
222
  ? ['goodvibes-admin', 'admin']
204
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
  }
@@ -325,7 +330,7 @@ export function isFieldSatisfied(controller: OnboardingWizardController, field:
325
330
  if (field.kind === 'radio') return true;
326
331
 
327
332
  if (field.kind === 'text' || field.kind === 'masked') {
328
- const required = field.required === true || controller.isRequiredExternalSetupField(field.id);
333
+ const required = field.required === true;
329
334
  if (!required) return true;
330
335
  return normalizeText(controller.getFieldValue(field) as string).length > 0;
331
336
  }