@pellux/goodvibes-tui 0.19.41 → 0.19.43
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 +20 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/config/secret-config.ts +119 -0
- package/src/input/commands/config.ts +34 -17
- package/src/input/onboarding/onboarding-wizard-helpers.ts +7 -57
- package/src/input/settings-modal-behavior.ts +37 -0
- package/src/input/settings-modal-secrets.ts +41 -0
- package/src/input/settings-modal.ts +28 -38
- package/src/renderer/settings-modal-helpers.ts +9 -0
- package/src/runtime/onboarding/derivation.ts +18 -5
- package/src/shell/ui-openers.ts +2 -2
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,26 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [0.19.43] — 2026-04-27
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Updated `@pellux/goodvibes-sdk` to `0.25.20` for SDK-owned Cloudflare Durable Object migration idempotency and retry fixes.
|
|
11
|
+
- Regenerated foundation operator contract artifacts from the SDK 0.25.20 contract.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Cloudflare Worker provisioning now benefits from SDK recovery when an existing `GoodVibesCoordinator` Durable Object SQLite migration has already been applied.
|
|
15
|
+
|
|
16
|
+
## [0.19.42] — 2026-04-27
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- 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.
|
|
20
|
+
- Regenerated foundation operator contract artifacts from the SDK 0.25.19 contract.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Home Assistant setup is now detected as an external onboarding surface even when configured values exist but `surfaces.homeassistant.enabled` is off.
|
|
24
|
+
- 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.
|
|
25
|
+
- `/config surfaces...` now exposes the surfaces category so Home Assistant settings can be inspected and changed outside the wizard.
|
|
26
|
+
|
|
7
27
|
## [0.19.41] — 2026-04-27
|
|
8
28
|
|
|
9
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.43",
|
|
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.
|
|
94
|
+
"@pellux/goodvibes-sdk": "0.25.20",
|
|
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
|
|
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
|
|
407
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
475
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
152
|
-
|
|
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
|
-
])
|
|
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
|
|
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) {
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -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.
|
|
9
|
+
let _version = '0.19.43';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|