@pellux/goodvibes-tui 0.19.41 → 0.19.42

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.19.42] — 2026-04-27
8
+
9
+ ### Changed
10
+ - Updated `@pellux/goodvibes-sdk` to `0.25.19` for SDK-owned Cloudflare Workers provisioning fixes around existing workers.dev subdomains, Worker cron settings, and workers.dev route disable flows.
11
+ - Regenerated foundation operator contract artifacts from the SDK 0.25.19 contract.
12
+
13
+ ### Fixed
14
+ - Home Assistant setup is now detected as an external onboarding surface even when configured values exist but `surfaces.homeassistant.enabled` is off.
15
+ - Home Assistant token and webhook secret edits from `/settings` and `/config` now store raw values through GoodVibes secrets and persist `goodvibes://secrets/goodvibes/...` refs in config.
16
+ - `/config surfaces...` now exposes the surfaces category so Home Assistant settings can be inspected and changed outside the wizard.
17
+
7
18
  ## [0.19.41] — 2026-04-27
8
19
 
9
20
  ### Changed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.41-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.19.42-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "0.25.18"
6
+ "version": "0.25.19"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.41",
3
+ "version": "0.19.42",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -91,7 +91,7 @@
91
91
  "@anthropic-ai/vertex-sdk": "^0.16.0",
92
92
  "@ast-grep/napi": "^0.42.0",
93
93
  "@aws/bedrock-token-generator": "^1.1.0",
94
- "@pellux/goodvibes-sdk": "0.25.18",
94
+ "@pellux/goodvibes-sdk": "0.25.19",
95
95
  "bash-language-server": "^5.6.0",
96
96
  "fuse.js": "^7.1.0",
97
97
  "graphql": "^16.13.2",
@@ -0,0 +1,119 @@
1
+ import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config/secret-refs';
2
+ import type { ConfigKey } from './index.ts';
3
+ import type { SecretScope, SecretStorageMedium } from './secrets.ts';
4
+
5
+ export const SECRET_CONFIG_KEYS = new Set<ConfigKey>([
6
+ 'surfaces.slack.signingSecret',
7
+ 'surfaces.slack.botToken',
8
+ 'surfaces.slack.appToken',
9
+ 'surfaces.discord.botToken',
10
+ 'surfaces.ntfy.token',
11
+ 'surfaces.webhook.secret',
12
+ 'surfaces.homeassistant.accessToken',
13
+ 'surfaces.homeassistant.webhookSecret',
14
+ 'surfaces.telegram.botToken',
15
+ 'surfaces.telegram.webhookSecret',
16
+ 'surfaces.googleChat.verificationToken',
17
+ 'surfaces.signal.token',
18
+ 'surfaces.whatsapp.accessToken',
19
+ 'surfaces.whatsapp.verifyToken',
20
+ 'surfaces.whatsapp.signingSecret',
21
+ 'surfaces.imessage.token',
22
+ 'surfaces.msteams.appPassword',
23
+ 'surfaces.bluebubbles.password',
24
+ 'surfaces.mattermost.botToken',
25
+ 'surfaces.matrix.accessToken',
26
+ ]);
27
+
28
+ export interface SecretBackedConfigUpdate {
29
+ readonly configValue: string;
30
+ readonly secretKey?: string;
31
+ readonly secretValue?: string;
32
+ readonly clearSecretKey?: string;
33
+ }
34
+
35
+ export interface SecretBackedConfigManager {
36
+ readonly get: (key: ConfigKey) => unknown;
37
+ readonly setDynamic: (key: ConfigKey, value: unknown) => void;
38
+ }
39
+
40
+ export interface SecretBackedSecretStore {
41
+ readonly set: (key: string, value: string, options?: { readonly scope?: SecretScope; readonly medium?: SecretStorageMedium }) => Promise<void>;
42
+ readonly delete?: (key: string, options?: { readonly scope?: SecretScope; readonly medium?: SecretStorageMedium }) => Promise<void>;
43
+ }
44
+
45
+ export function isSecretConfigKey(key: string): key is ConfigKey {
46
+ return SECRET_CONFIG_KEYS.has(key as ConfigKey);
47
+ }
48
+
49
+ export function normalizeSecretKeyPart(value: string): string {
50
+ return value
51
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
52
+ .replace(/[^a-zA-Z0-9]+/g, '_')
53
+ .replace(/^_+|_+$/g, '')
54
+ .toUpperCase();
55
+ }
56
+
57
+ export function buildGoodVibesSecretKey(configKey: string): string {
58
+ return `GOODVIBES_${configKey.split('.').map(normalizeSecretKeyPart).filter(Boolean).join('_')}`;
59
+ }
60
+
61
+ export function buildGoodVibesSecretRef(secretKey: string): string {
62
+ return `goodvibes://secrets/goodvibes/${encodeURIComponent(secretKey)}`;
63
+ }
64
+
65
+ export function isSecretReferenceValue(value: string): boolean {
66
+ const normalized = value.trim();
67
+ return normalized.startsWith('goodvibes://secrets/') && isSecretRefInput(normalized);
68
+ }
69
+
70
+ export function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
71
+ const normalized = value.trim();
72
+ return normalized.startsWith('goodvibes://') && !isSecretReferenceValue(normalized);
73
+ }
74
+
75
+ export function getSecretWriteMedium(policy: unknown): SecretStorageMedium {
76
+ if (policy === 'plaintext_allowed') return 'plaintext';
77
+ return 'secure';
78
+ }
79
+
80
+ export function buildSecretBackedConfigUpdate(configKey: ConfigKey, rawValue: string): SecretBackedConfigUpdate {
81
+ const value = rawValue.trim();
82
+ const secretKey = buildGoodVibesSecretKey(configKey);
83
+ if (value.length === 0) {
84
+ return {
85
+ configValue: '',
86
+ clearSecretKey: secretKey,
87
+ };
88
+ }
89
+ if (isSecretReferenceValue(value)) {
90
+ return { configValue: value };
91
+ }
92
+ return {
93
+ configValue: buildGoodVibesSecretRef(secretKey),
94
+ secretKey,
95
+ secretValue: rawValue,
96
+ };
97
+ }
98
+
99
+ export async function persistSecretBackedConfigValue(
100
+ configManager: SecretBackedConfigManager,
101
+ secretsManager: SecretBackedSecretStore | null | undefined,
102
+ configKey: ConfigKey,
103
+ rawValue: string,
104
+ options: { readonly scope?: SecretScope } = {},
105
+ ): Promise<string> {
106
+ const update = buildSecretBackedConfigUpdate(configKey, rawValue);
107
+ const scope = options.scope ?? 'user';
108
+ if (update.secretKey && update.secretValue !== undefined && secretsManager) {
109
+ await secretsManager.set(update.secretKey, update.secretValue, {
110
+ scope,
111
+ medium: getSecretWriteMedium(configManager.get('storage.secretPolicy')),
112
+ });
113
+ }
114
+ if (update.clearSecretKey && secretsManager?.delete) {
115
+ await secretsManager.delete(update.clearSecretKey, { scope });
116
+ }
117
+ configManager.setDynamic(configKey, update.configValue);
118
+ return update.configValue;
119
+ }
@@ -1,9 +1,10 @@
1
1
  import type { CommandRegistry } from '../command-registry.ts';
2
2
  import { CONFIG_SCHEMA, type ConfigKey } from '../../config/index.ts';
3
+ import { buildGoodVibesSecretKey, isSecretConfigKey, isSecretReferenceValue, persistSecretBackedConfigValue } from '../../config/secret-config.ts';
3
4
  import { configSnapshotToProfileData, profileDataToConfigSnapshot } from '@pellux/goodvibes-sdk/platform/profiles/shape';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { requireProfileManager, requireProviderApi, requireShellPaths } from './runtime-services.ts';
7
+ import { requireProfileManager, requireProviderApi, requireSecretsManager, requireShellPaths } from './runtime-services.ts';
7
8
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
8
9
 
9
10
  interface ConfigBundle {
@@ -78,6 +79,22 @@ function buildConfigSnapshot(
78
79
  return snapshot;
79
80
  }
80
81
 
82
+ function formatConfigSchemaEntry(
83
+ manager: { get: (key: ConfigKey) => unknown },
84
+ schema: { readonly key: ConfigKey; readonly description: string },
85
+ ): string {
86
+ let value: unknown = '(unavailable)';
87
+ try {
88
+ value = manager.get(schema.key);
89
+ } catch {
90
+ // keep unavailable marker
91
+ }
92
+ const displayValue = typeof value === 'string' && isSecretConfigKey(schema.key) && !isSecretReferenceValue(value)
93
+ ? value.length === 0 ? '(empty)' : '[secret]'
94
+ : String(value);
95
+ return ` ${schema.key.padEnd(46)} ${displayValue} — ${schema.description}`;
96
+ }
97
+
81
98
  function coerceValue(
82
99
  raw: string,
83
100
  type: 'boolean' | 'number' | 'string' | 'enum',
@@ -113,8 +130,7 @@ export function registerConfigCommand(registry: CommandRegistry): void {
113
130
  argsHint: '<key> [value]',
114
131
  async handler(args, ctx) {
115
132
  const cm = ctx.platform.configManager;
116
- const all = cm.getAll();
117
- const categories = ['display', 'provider', 'behavior', 'permissions', 'danger', 'tools'] as const;
133
+ const categories = ['display', 'ui', 'provider', 'behavior', 'storage', 'permissions', 'surfaces', 'cloudflare', 'batch', 'tts', 'danger', 'tools'] as const;
118
134
 
119
135
  if (args[0] === 'profile') {
120
136
  const sub = args[1];
@@ -310,6 +326,10 @@ export function registerConfigCommand(registry: CommandRegistry): void {
310
326
  }
311
327
  try {
312
328
  cm.setDynamic(resetKey as ConfigKey, schema.default);
329
+ if (isSecretConfigKey(resetKey)) {
330
+ const secretsManager = requireSecretsManager(ctx);
331
+ await secretsManager.delete(buildGoodVibesSecretKey(resetKey), { scope: 'user' });
332
+ }
313
333
  if (resetKey === 'provider.model') ctx.session.runtime.model = schema.default as string;
314
334
  if (resetKey === 'provider.provider') ctx.session.runtime.provider = schema.default as string;
315
335
  if (resetKey === 'provider.reasoningEffort') ctx.session.runtime.reasoningEffort = schema.default as string;
@@ -403,12 +423,8 @@ export function registerConfigCommand(registry: CommandRegistry): void {
403
423
  const lines: string[] = ['Config settings:'];
404
424
  for (const cat of categories) {
405
425
  lines.push(` [${cat}]`);
406
- const catObj = all[cat] as Record<string, unknown>;
407
- for (const [field, val] of Object.entries(catObj)) {
408
- const key = `${cat}.${field}`;
409
- const schema = CONFIG_SCHEMA.find((entry) => entry.key === key);
410
- const desc = schema ? ` — ${schema.description}` : '';
411
- lines.push(` ${key.padEnd(36)} ${String(val)}${desc}`);
426
+ for (const schema of CONFIG_SCHEMA.filter((entry) => entry.key.startsWith(`${cat}.`))) {
427
+ lines.push(` ${formatConfigSchemaEntry(cm, schema)}`);
412
428
  }
413
429
  }
414
430
  ctx.print(lines.join('\n'));
@@ -418,13 +434,9 @@ export function registerConfigCommand(registry: CommandRegistry): void {
418
434
  const firstArg = args[0];
419
435
  if (categories.includes(firstArg as typeof categories[number]) && args.length === 1) {
420
436
  const cat = firstArg as typeof categories[number];
421
- const catObj = all[cat] as Record<string, unknown>;
422
437
  const lines: string[] = [`[${cat}]`];
423
- for (const [field, val] of Object.entries(catObj)) {
424
- const key = `${cat}.${field}`;
425
- const schema = CONFIG_SCHEMA.find((entry) => entry.key === key);
426
- const desc = schema ? ` — ${schema.description}` : '';
427
- lines.push(` ${key.padEnd(36)} ${String(val)}${desc}`);
438
+ for (const schema of CONFIG_SCHEMA.filter((entry) => entry.key.startsWith(`${cat}.`))) {
439
+ lines.push(formatConfigSchemaEntry(cm, schema));
428
440
  }
429
441
  ctx.print(lines.join('\n'));
430
442
  return;
@@ -471,8 +483,13 @@ export function registerConfigCommand(registry: CommandRegistry): void {
471
483
  }
472
484
 
473
485
  try {
474
- cm.setDynamic(key, coerced);
475
- ctx.print(`Set ${key} = ${String(coerced)}`);
486
+ if (schema.type === 'string' && isSecretConfigKey(key)) {
487
+ const configValue = await persistSecretBackedConfigValue(cm, requireSecretsManager(ctx), key, String(coerced), { scope: 'user' });
488
+ ctx.print(`Set ${key} = ${configValue}`);
489
+ } else {
490
+ cm.setDynamic(key, coerced);
491
+ ctx.print(`Set ${key} = ${String(coerced)}`);
492
+ }
476
493
  if (key === 'provider.model') ctx.session.runtime.model = coerced as string;
477
494
  if (key === 'provider.provider') ctx.session.runtime.provider = coerced as string;
478
495
  if (key === 'provider.reasoningEffort') ctx.session.runtime.reasoningEffort = coerced as string;
@@ -3,6 +3,13 @@ import { deriveOnboardingStepState, type OnboardingStep1CapabilityItem, type Onb
3
3
  import { DEFAULT_CAPABILITIES } from './onboarding-wizard-constants.ts';
4
4
  import { EXTERNAL_SURFACE_SPECS, type ExternalSurfaceSetupFieldSpec, type ExternalSurfaceSpec } from './onboarding-wizard-external-surfaces.ts';
5
5
  import type { OnboardingWizardAcknowledgementFieldDefinition, OnboardingWizardModelSelection, OnboardingWizardRuntimeHydration } from './onboarding-wizard-types.ts';
6
+ export {
7
+ buildGoodVibesSecretKey,
8
+ buildGoodVibesSecretRef,
9
+ isMalformedGoodVibesSecretReferenceValue,
10
+ isSecretReferenceValue,
11
+ normalizeSecretKeyPart,
12
+ } from '../../config/secret-config.ts';
6
13
 
7
14
  export function clamp(value: number, min: number, max: number): number {
8
15
  return Math.max(min, Math.min(max, value));
@@ -16,63 +23,6 @@ export function normalizeText(value: string | null | undefined): string {
16
23
  return (value ?? '').trim();
17
24
  }
18
25
 
19
- export function normalizeSecretKeyPart(value: string): string {
20
- return value
21
- .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
22
- .replace(/[^a-zA-Z0-9]+/g, '_')
23
- .replace(/^_+|_+$/g, '')
24
- .toUpperCase();
25
- }
26
-
27
- export function buildGoodVibesSecretKey(configKey: string): string {
28
- return `GOODVIBES_${configKey.split('.').map(normalizeSecretKeyPart).filter(Boolean).join('_')}`;
29
- }
30
-
31
- export function buildGoodVibesSecretRef(secretKey: string): string {
32
- return `goodvibes://secrets/goodvibes/${encodeURIComponent(secretKey)}`;
33
- }
34
-
35
- export function isSecretReferenceValue(value: string): boolean {
36
- const normalized = value.trim();
37
- if (normalized.length === 0) return false;
38
- let url: URL;
39
- try {
40
- url = new URL(normalized);
41
- } catch {
42
- return false;
43
- }
44
- if (url.protocol !== 'goodvibes:' || url.hostname !== 'secrets') return false;
45
- const segments = url.pathname.split('/').filter(Boolean).map((segment) => decodeURIComponent(segment));
46
- const source = (segments[0] ?? '').toLowerCase();
47
- const rest = segments.slice(1);
48
- const params = url.searchParams;
49
- if (source === 'env' || source === 'goodvibes') {
50
- return Boolean(rest[0] ?? params.get('id') ?? params.get('key') ?? params.get('name'));
51
- }
52
- if (source === 'file') {
53
- return Boolean(rest.length > 0 || params.get('path'));
54
- }
55
- if (source === 'exec') {
56
- return Boolean(rest[0] ?? params.get('command') ?? params.get('cmd'));
57
- }
58
- if (source === '1password' || source === 'onepassword' || source === 'op') {
59
- return Boolean(params.get('ref') ?? params.get('uri'))
60
- || Boolean((params.get('vault') ?? rest[0]) && (params.get('item') ?? rest[1]) && (params.get('field') ?? rest[2]));
61
- }
62
- if (source === 'bitwarden' || source === 'vaultwarden') {
63
- return Boolean(rest[0] ?? params.get('item') ?? params.get('id') ?? params.get('name'));
64
- }
65
- if (source === 'bitwarden-secrets-manager' || source === 'bws') {
66
- return Boolean(rest[0] ?? params.get('id') ?? params.get('secretId') ?? params.get('secret'));
67
- }
68
- return false;
69
- }
70
-
71
- export function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
72
- const normalized = value.trim();
73
- return normalized.startsWith('goodvibes://') && !isSecretReferenceValue(normalized);
74
- }
75
-
76
26
  export function isValidHostValue(value: string): boolean {
77
27
  const normalized = value.trim();
78
28
  if (normalized.length === 0) return false;
@@ -0,0 +1,37 @@
1
+ import type { ConfigSetting } from '@pellux/goodvibes-sdk/platform/config/schema';
2
+ import type { ModelPickerTarget } from './model-picker.ts';
3
+
4
+ export type ModelPickerLaunch =
5
+ | { readonly flow: 'providerModel'; readonly target: ModelPickerTarget }
6
+ | { readonly flow: 'model'; readonly target: ModelPickerTarget };
7
+
8
+ /**
9
+ * Map config keys to the shared provider/model picker flows. Provider rows open
10
+ * provider first; model rows open directly to models for the same target.
11
+ */
12
+ export function modelPickerLaunchForKey(key: string): ModelPickerLaunch | null {
13
+ if (key === 'helper.globalProvider') return { flow: 'providerModel', target: 'helper' };
14
+ if (key === 'helper.globalModel') return { flow: 'model', target: 'helper' };
15
+ if (key === 'tools.llmProvider') return { flow: 'providerModel', target: 'tool' };
16
+ if (key === 'tools.llmModel') return { flow: 'model', target: 'tool' };
17
+ if (key === 'tts.llmProvider') return { flow: 'providerModel', target: 'tts' };
18
+ if (key === 'tts.llmModel') return { flow: 'model', target: 'tts' };
19
+ return null;
20
+ }
21
+
22
+ export function roundToPrecision(value: number, precision: number): number {
23
+ const factor = 10 ** precision;
24
+ return Math.round(value * factor) / factor;
25
+ }
26
+
27
+ export function getNumericAdjustmentMeta(setting: ConfigSetting): {
28
+ step: number;
29
+ min?: number;
30
+ max?: number;
31
+ precision: number;
32
+ } {
33
+ if (setting.key === 'wrfc.scoreThreshold') {
34
+ return { step: 0.1, min: 0, max: 10, precision: 1 };
35
+ }
36
+ return { step: 1, precision: 0 };
37
+ }
@@ -0,0 +1,41 @@
1
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config/schema';
2
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
3
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
4
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils/error-display';
5
+ import type { SecretsManager } from '../config/secrets.ts';
6
+ import {
7
+ buildSecretBackedConfigUpdate,
8
+ getSecretWriteMedium,
9
+ } from '../config/secret-config.ts';
10
+
11
+ export type SettingsSecretsManager = Pick<SecretsManager, 'delete' | 'set'>;
12
+
13
+ export function setSecretBackedSettingValue(args: {
14
+ key: ConfigKey;
15
+ value: string;
16
+ configManager: ConfigManager;
17
+ secretsManager: SettingsSecretsManager | null;
18
+ setConfigValue: (key: ConfigKey, value: unknown) => void;
19
+ }): void {
20
+ const { key, value, configManager, secretsManager, setConfigValue } = args;
21
+ if (!secretsManager) {
22
+ setConfigValue(key, value.trim());
23
+ return;
24
+ }
25
+
26
+ const update = buildSecretBackedConfigUpdate(key, value);
27
+ if (update.secretKey && update.secretValue !== undefined) {
28
+ void secretsManager.set(update.secretKey, update.secretValue, {
29
+ scope: 'user',
30
+ medium: getSecretWriteMedium(configManager.get('storage.secretPolicy')),
31
+ }).catch((error) => {
32
+ logger.error('SettingsModal: failed to store secret config value', { key, error: summarizeError(error) });
33
+ });
34
+ }
35
+ if (update.clearSecretKey) {
36
+ void secretsManager.delete(update.clearSecretKey, { scope: 'user' }).catch((error) => {
37
+ logger.error('SettingsModal: failed to clear secret config value', { key, error: summarizeError(error) });
38
+ });
39
+ }
40
+ setConfigValue(key, update.configValue);
41
+ }
@@ -10,7 +10,7 @@
10
10
  * Saves changes via configManager.set(key, value) or featureFlagManager methods.
11
11
  */
12
12
 
13
- import { CONFIG_SCHEMA, type ConfigSetting, type ConfigKey, type PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config/schema';
13
+ import { CONFIG_SCHEMA, type ConfigKey, type PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config/schema';
14
14
  import type { ModelPickerTarget } from './model-picker.ts';
15
15
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
16
16
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config/subscriptions';
@@ -18,6 +18,16 @@ import { listBuiltinSubscriptionProviders } from '@pellux/goodvibes-sdk/platform
18
18
  import type { ProviderAuthFreshness, ProviderAuthRoute } from '@pellux/goodvibes-sdk/platform/runtime/provider-accounts/registry';
19
19
  import { getResolvedSettingLookup } from '@pellux/goodvibes-sdk/platform/runtime/settings/control-plane';
20
20
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
21
+ import { isSecretConfigKey } from '../config/secret-config.ts';
22
+ import {
23
+ getNumericAdjustmentMeta,
24
+ modelPickerLaunchForKey,
25
+ roundToPrecision,
26
+ } from './settings-modal-behavior.ts';
27
+ import {
28
+ setSecretBackedSettingValue,
29
+ type SettingsSecretsManager,
30
+ } from './settings-modal-secrets.ts';
21
31
  import type { FeatureFlagManager } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/index';
22
32
  import type { FeatureFlag, FlagState } from '@pellux/goodvibes-sdk/platform/runtime/feature-flags/types';
23
33
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp/registry';
@@ -41,41 +51,6 @@ export {
41
51
  type SubscriptionEntry,
42
52
  } from './settings-modal-types.ts';
43
53
 
44
- type ModelPickerLaunch =
45
- | { readonly flow: 'providerModel'; readonly target: ModelPickerTarget }
46
- | { readonly flow: 'model'; readonly target: ModelPickerTarget };
47
-
48
- /**
49
- * Map config keys to the shared provider/model picker flows. Provider rows open
50
- * provider first; model rows open directly to models for the same target.
51
- */
52
- function _modelPickerLaunchForKey(key: string): ModelPickerLaunch | null {
53
- if (key === 'helper.globalProvider') return { flow: 'providerModel', target: 'helper' };
54
- if (key === 'helper.globalModel') return { flow: 'model', target: 'helper' };
55
- if (key === 'tools.llmProvider') return { flow: 'providerModel', target: 'tool' };
56
- if (key === 'tools.llmModel') return { flow: 'model', target: 'tool' };
57
- if (key === 'tts.llmProvider') return { flow: 'providerModel', target: 'tts' };
58
- if (key === 'tts.llmModel') return { flow: 'model', target: 'tts' };
59
- return null;
60
- }
61
-
62
- function roundToPrecision(value: number, precision: number): number {
63
- const factor = 10 ** precision;
64
- return Math.round(value * factor) / factor;
65
- }
66
-
67
- function getNumericAdjustmentMeta(setting: ConfigSetting): {
68
- step: number;
69
- min?: number;
70
- max?: number;
71
- precision: number;
72
- } {
73
- if (setting.key === 'wrfc.scoreThreshold') {
74
- return { step: 0.1, min: 0, max: 10, precision: 1 };
75
- }
76
- return { step: 1, precision: 0 };
77
- }
78
-
79
54
  // ---------------------------------------------------------------------------
80
55
  // SettingsModal
81
56
  // ---------------------------------------------------------------------------
@@ -125,6 +100,7 @@ export class SettingsModal {
125
100
  public lastSaveTriggeredRestart: 'control-plane' | 'http-listener' | 'web' | null = null;
126
101
 
127
102
  private configManager: ConfigManager | null = null;
103
+ private secretsManager: SettingsSecretsManager | null = null;
128
104
  private featureFlagManager: FeatureFlagManager | null = null;
129
105
  private mcpRegistry: McpRegistry | null = null;
130
106
  private subscriptionManager: SubscriptionManager | null = null;
@@ -142,8 +118,10 @@ export class SettingsModal {
142
118
  subscriptionManager: SubscriptionManager,
143
119
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
144
120
  mcpRegistry?: McpRegistry,
121
+ secretsManager?: SettingsSecretsManager,
145
122
  ): void {
146
123
  this.configManager = configManager;
124
+ this.secretsManager = secretsManager ?? null;
147
125
  this.featureFlagManager = featureFlagManager;
148
126
  this.subscriptionManager = subscriptionManager;
149
127
  this.serviceRegistry = serviceRegistry;
@@ -174,6 +152,7 @@ export class SettingsModal {
174
152
  this.subscriptionLogoutConfirmationTarget = null;
175
153
  this.lastSaveTriggeredRestart = null;
176
154
  this.serviceRegistry = null;
155
+ this.secretsManager = null;
177
156
  }
178
157
 
179
158
  /** Cycle to the next category (Tab). */
@@ -304,7 +283,7 @@ export class SettingsModal {
304
283
  const { setting } = entry;
305
284
 
306
285
  // Delegate provider/model picker settings to the model picker UI
307
- const pickerLaunch = _modelPickerLaunchForKey(setting.key);
286
+ const pickerLaunch = modelPickerLaunchForKey(setting.key);
308
287
  if (pickerLaunch !== null) {
309
288
  if (pickerLaunch.flow === 'providerModel') {
310
289
  this.pendingProviderModelPickerTarget = pickerLaunch.target;
@@ -495,7 +474,17 @@ export class SettingsModal {
495
474
  return false;
496
475
  }
497
476
 
498
- this._setValue(setting.key, parsed);
477
+ if (setting.type === 'string' && isSecretConfigKey(setting.key)) {
478
+ setSecretBackedSettingValue({
479
+ key: setting.key,
480
+ value: String(parsed ?? ''),
481
+ configManager: this.configManager,
482
+ secretsManager: this.secretsManager,
483
+ setConfigValue: (key, value) => this._setValue(key, value),
484
+ });
485
+ } else {
486
+ this._setValue(setting.key, parsed);
487
+ }
499
488
  this.editingMode = false;
500
489
  this.editBuffer = '';
501
490
  return true;
@@ -787,4 +776,5 @@ export class SettingsModal {
787
776
  logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
788
777
  }
789
778
  }
779
+
790
780
  }
@@ -6,11 +6,20 @@
6
6
 
7
7
  import type { SettingEntry, McpEntry, SubscriptionEntry } from '../input/settings-modal.ts';
8
8
  import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
9
+ import { isSecretConfigKey, isSecretReferenceValue } from '../config/secret-config.ts';
10
+
11
+ function maskSecretValue(value: string): string {
12
+ if (value.length === 0) return '(empty)';
13
+ if (isSecretReferenceValue(value)) return value;
14
+ if (value.length <= 4) return '••••';
15
+ return `${'•'.repeat(Math.min(12, Math.max(4, value.length - 4)))}${value.slice(-4)}`;
16
+ }
9
17
 
10
18
  export function formatValue(entry: SettingEntry): string {
11
19
  const val = entry.currentValue;
12
20
  if (val === null || val === undefined) return '(unset)';
13
21
  if (typeof val === 'boolean') return val ? 'true' : 'false';
22
+ if (typeof val === 'string' && isSecretConfigKey(entry.setting.key)) return maskSecretValue(val);
14
23
  if (typeof val === 'string' && val === '') return '(empty)';
15
24
  return String(val);
16
25
  }
@@ -54,6 +54,7 @@ const INBOUND_EVENT_SURFACE_KINDS = new Set<string>([
54
54
  'discord',
55
55
  'google-chat',
56
56
  'googleChat',
57
+ 'homeassistant',
57
58
  'imessage',
58
59
  'mattermost',
59
60
  'matrix',
@@ -148,11 +149,24 @@ function hasConfiguredProviderState(snapshot: OnboardingSnapshotState): boolean
148
149
  return getConfiguredProviderSignalIds(snapshot).length > 0;
149
150
  }
150
151
 
151
- function countConfiguredSurfaceKinds(snapshot: OnboardingSnapshotState): number {
152
- return new Set<string>([
152
+ function getConfiguredSurfaceKinds(snapshot: OnboardingSnapshotState): string[] {
153
+ const kinds = new Set<string>([
153
154
  ...snapshot.surfaces.configuredEnabledKinds,
154
155
  ...snapshot.surfaces.records.filter((surface) => surface.enabled).map((surface) => surface.kind),
155
- ]).size;
156
+ ]);
157
+
158
+ for (const [kind, value] of Object.entries(snapshot.config.surfaces)) {
159
+ if (!value || typeof value !== 'object') continue;
160
+ const defaults = DEFAULT_CONFIG.surfaces[kind as keyof typeof DEFAULT_CONFIG.surfaces];
161
+ if (!defaults || typeof defaults !== 'object') continue;
162
+ if (!isDeepEqual(value, defaults)) kinds.add(kind);
163
+ }
164
+
165
+ return [...kinds].sort((left, right) => left.localeCompare(right));
166
+ }
167
+
168
+ function countConfiguredSurfaceKinds(snapshot: OnboardingSnapshotState): number {
169
+ return getConfiguredSurfaceKinds(snapshot).length;
156
170
  }
157
171
 
158
172
  function hasInboundEventSurface(snapshot: OnboardingSnapshotState): boolean {
@@ -276,8 +290,7 @@ function describeWebhookIngress(snapshot: OnboardingSnapshotState): string {
276
290
  function describeExternalIntegrations(snapshot: OnboardingSnapshotState): string {
277
291
  const integrationCount = new Set<string>([
278
292
  ...getExternalIntegrationServiceIds(snapshot),
279
- ...snapshot.surfaces.configuredEnabledKinds,
280
- ...snapshot.surfaces.records.filter((surface) => surface.enabled).map((surface) => surface.kind),
293
+ ...getConfiguredSurfaceKinds(snapshot),
281
294
  ]).size;
282
295
 
283
296
  if (integrationCount === 0) {
@@ -22,7 +22,7 @@ type WireShellUiOpenersOptions = {
22
22
  featureFlags: FeatureFlagManager;
23
23
  mcpRegistry: McpRegistry;
24
24
  subscriptionManager: SubscriptionManager;
25
- secretsManager?: Pick<SecretsManager, 'get'>;
25
+ secretsManager?: Pick<SecretsManager, 'delete' | 'get' | 'set'>;
26
26
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
27
27
  getConfiguredProviderIds: () => string[];
28
28
  getPinned: () => Promise<string[]>;
@@ -208,7 +208,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
208
208
 
209
209
  commandContext.openSettingsModal = () => {
210
210
  input.modalOpened('settings');
211
- input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry);
211
+ input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager);
212
212
  render();
213
213
  };
214
214
 
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.41';
9
+ let _version = '0.19.42';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;