@pellux/goodvibes-tui 0.19.64 → 0.19.66
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 +40 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/service-posture.ts +73 -3
- package/src/input/settings-modal-types.ts +13 -31
- package/src/input/settings-modal.ts +42 -1
- package/src/main.ts +2 -0
- package/src/renderer/settings-modal.ts +40 -8
- package/src/shell/service-settings-sync.ts +273 -0
- package/src/shell/ui-openers.ts +11 -1
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,46 @@ All notable changes to GoodVibes TUI.
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## [0.19.66] — 2026-05-06
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Changed `/config` to open with focus on the category rail at the first category instead of dropping users directly into the selected setting list.
|
|
15
|
+
- Reorganized the `/config` category rail into logical groups so related settings are easier to scan.
|
|
16
|
+
- Corrected `goodvibes service status` / service posture reporting for systemd-managed daemons by reconciling status against `systemctl --user show`, so running/enabled OS services are no longer reported as stopped because of stale pid-file state.
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
- `bun test src/test/input/settings-modal.test.ts src/test/renderer/settings-modal.test.ts`
|
|
20
|
+
- `bun test src/test/input/modal-space-actions.test.ts`
|
|
21
|
+
- `bun test src/test/cli/service-posture.test.ts`
|
|
22
|
+
- `bun run test`
|
|
23
|
+
- `bunx tsc --noEmit`
|
|
24
|
+
- `bun run build:prod`
|
|
25
|
+
- `bun run smoke:tui`
|
|
26
|
+
- `bun run smoke:daemon`
|
|
27
|
+
- `git diff --check`
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## [0.19.65] — 2026-05-05
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- Made `/config service` changes apply to the OS service layer instead of only writing `~/.goodvibes/tui/settings.json`.
|
|
35
|
+
- Enabling `service.autostart` now enables service mode if needed, writes the platform service definition, reloads systemd user units when applicable, and starts/enables the service.
|
|
36
|
+
- Disabling `service.autostart` or `service.enabled` now disables/removes the platform service definition instead of leaving stale OS autostart state behind.
|
|
37
|
+
- Service definition changes such as restart-on-failure, platform, service name, or log path now rewrite and restart the OS service when service mode and autostart are active.
|
|
38
|
+
- Added a visible `/config` status notice for service install/start/disable/update results after a setting change.
|
|
39
|
+
|
|
40
|
+
### Verified
|
|
41
|
+
- `bun test src/test/shell/service-settings-sync.test.ts src/test/input/settings-modal-network.test.ts`
|
|
42
|
+
- `bun run test`
|
|
43
|
+
- `bunx tsc --noEmit`
|
|
44
|
+
- `bun run build:prod`
|
|
45
|
+
- `bun run smoke:tui`
|
|
46
|
+
- `bun run smoke:daemon`
|
|
47
|
+
- `git diff --check`
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
11
51
|
## [0.19.64] — 2026-05-05
|
|
12
52
|
|
|
13
53
|
### Fixed
|
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.66",
|
|
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",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
1
2
|
import { accessSync, closeSync, constants, existsSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
2
3
|
import net from 'node:net';
|
|
3
4
|
import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
@@ -82,6 +83,23 @@ interface ServiceDefinitionOverride {
|
|
|
82
83
|
readonly restartOnFailure: boolean;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
interface CliServiceStatusCommandResult {
|
|
87
|
+
readonly status: number | null;
|
|
88
|
+
readonly stdout?: string;
|
|
89
|
+
readonly stderr?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface CliServiceStatusManager {
|
|
93
|
+
status(): ManagedServiceStatus;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface CliServicePostureOptions {
|
|
97
|
+
readonly probe?: boolean;
|
|
98
|
+
readonly logTailBytes?: number;
|
|
99
|
+
readonly manager?: CliServiceStatusManager;
|
|
100
|
+
readonly runCommand?: (command: string, args: readonly string[]) => CliServiceStatusCommandResult;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
function connectHostForBindHost(host: string): string {
|
|
86
104
|
if (host === '0.0.0.0' || host === '::') return '127.0.0.1';
|
|
87
105
|
return host || '127.0.0.1';
|
|
@@ -227,6 +245,58 @@ function pidFilePath(runtime: CliServiceRuntime, platform: ManagedServiceStatus[
|
|
|
227
245
|
return join(getServiceStateRoot(runtime), 'service', `${platform}.pid`);
|
|
228
246
|
}
|
|
229
247
|
|
|
248
|
+
function runStatusCommand(
|
|
249
|
+
command: string,
|
|
250
|
+
args: readonly string[],
|
|
251
|
+
options: CliServicePostureOptions,
|
|
252
|
+
): CliServiceStatusCommandResult {
|
|
253
|
+
if (options.runCommand) return options.runCommand(command, args);
|
|
254
|
+
return spawnSync(command, [...args], {
|
|
255
|
+
stdio: 'pipe',
|
|
256
|
+
encoding: 'utf-8',
|
|
257
|
+
timeout: 1500,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseSystemdShowValue(lines: readonly string[], key: string): string | null {
|
|
262
|
+
const prefix = `${key}=`;
|
|
263
|
+
const match = lines.find((line) => line.startsWith(prefix));
|
|
264
|
+
return match ? match.slice(prefix.length).trim() : null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function reconcileSystemdServiceStatus(
|
|
268
|
+
runtime: CliServiceRuntime,
|
|
269
|
+
status: ManagedServiceStatus,
|
|
270
|
+
options: CliServicePostureOptions,
|
|
271
|
+
): ManagedServiceStatus {
|
|
272
|
+
if (status.platform !== 'systemd') return status;
|
|
273
|
+
|
|
274
|
+
const name = String(runtime.configManager.get('service.serviceName') ?? 'goodvibes').trim() || 'goodvibes';
|
|
275
|
+
const result = runStatusCommand('systemctl', [
|
|
276
|
+
'--user',
|
|
277
|
+
'show',
|
|
278
|
+
`${name}.service`,
|
|
279
|
+
'--property=LoadState,ActiveState,UnitFileState,MainPID',
|
|
280
|
+
'--no-page',
|
|
281
|
+
], options);
|
|
282
|
+
if ((result.status ?? 1) !== 0) return status;
|
|
283
|
+
|
|
284
|
+
const lines = (result.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
285
|
+
const loadState = parseSystemdShowValue(lines, 'LoadState');
|
|
286
|
+
const activeState = parseSystemdShowValue(lines, 'ActiveState');
|
|
287
|
+
const unitFileState = parseSystemdShowValue(lines, 'UnitFileState');
|
|
288
|
+
const rawPid = Number.parseInt(parseSystemdShowValue(lines, 'MainPID') ?? '', 10);
|
|
289
|
+
const pid = Number.isFinite(rawPid) && rawPid > 0 ? rawPid : undefined;
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
...status,
|
|
293
|
+
installed: loadState === 'loaded' || status.installed,
|
|
294
|
+
autostart: unitFileState === 'enabled' || unitFileState === 'linked' || status.autostart,
|
|
295
|
+
running: activeState === 'active',
|
|
296
|
+
...(pid === undefined ? {} : { pid }),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
230
300
|
function readLogPosture(path: string | undefined, tailBytes: number): CliServiceLogPosture {
|
|
231
301
|
if (!path) return { path: null, exists: false, size: 0, modifiedAt: null };
|
|
232
302
|
if (!existsSync(path)) return { path, exists: false, size: 0, modifiedAt: null };
|
|
@@ -298,10 +368,10 @@ export function createPlatformServiceManager(runtime: CliServiceRuntime): Platfo
|
|
|
298
368
|
|
|
299
369
|
export async function buildCliServicePosture(
|
|
300
370
|
runtime: CliServiceRuntime,
|
|
301
|
-
options:
|
|
371
|
+
options: CliServicePostureOptions = {},
|
|
302
372
|
): Promise<CliServicePosture> {
|
|
303
|
-
const manager = createPlatformServiceManager(runtime);
|
|
304
|
-
const status = manager.status();
|
|
373
|
+
const manager = options.manager ?? createPlatformServiceManager(runtime);
|
|
374
|
+
const status = reconcileSystemdServiceStatus(runtime, manager.status(), options);
|
|
305
375
|
const endpoints = await Promise.all(ENDPOINTS.map(async (endpoint): Promise<CliServiceEndpointPosture> => {
|
|
306
376
|
const enabled = runtime.configManager.get(endpoint.enabledKey as never) === true;
|
|
307
377
|
const binding = resolveRuntimeEndpointBinding(runtime.configManager, endpoint.id);
|
|
@@ -36,39 +36,21 @@ export type SettingsCategory =
|
|
|
36
36
|
|
|
37
37
|
export type SettingsFocusPane = 'categories' | 'settings';
|
|
38
38
|
|
|
39
|
-
export const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'
|
|
44
|
-
'
|
|
45
|
-
'
|
|
46
|
-
'
|
|
47
|
-
'orchestration',
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'helper',
|
|
51
|
-
'tts',
|
|
52
|
-
'service',
|
|
53
|
-
'controlPlane',
|
|
54
|
-
'httpListener',
|
|
55
|
-
'web',
|
|
56
|
-
'network',
|
|
57
|
-
'mcp',
|
|
58
|
-
'sandbox',
|
|
59
|
-
'surfaces',
|
|
60
|
-
'cloudflare',
|
|
61
|
-
'batch',
|
|
62
|
-
'automation',
|
|
63
|
-
'watchers',
|
|
64
|
-
'runtime',
|
|
65
|
-
'telemetry',
|
|
66
|
-
'cache',
|
|
67
|
-
'danger',
|
|
68
|
-
'flags',
|
|
69
|
-
'release',
|
|
39
|
+
export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
|
|
40
|
+
readonly label: string;
|
|
41
|
+
readonly categories: readonly SettingsCategory[];
|
|
42
|
+
}> = [
|
|
43
|
+
{ label: 'Interface', categories: ['display', 'ui', 'behavior', 'permissions'] },
|
|
44
|
+
{ label: 'AI Routing', categories: ['provider', 'subscriptions', 'helper', 'tools', 'tts'] },
|
|
45
|
+
{ label: 'Service & Network', categories: ['service', 'network', 'controlPlane', 'httpListener', 'web'] },
|
|
46
|
+
{ label: 'Surfaces & Cloud', categories: ['surfaces', 'mcp', 'cloudflare'] },
|
|
47
|
+
{ label: 'Automation', categories: ['batch', 'automation', 'watchers', 'orchestration', 'wrfc'] },
|
|
48
|
+
{ label: 'Runtime & Data', categories: ['storage', 'sandbox', 'runtime', 'cache', 'telemetry'] },
|
|
49
|
+
{ label: 'Advanced', categories: ['flags', 'release', 'danger'] },
|
|
70
50
|
];
|
|
71
51
|
|
|
52
|
+
export const SETTINGS_CATEGORIES: SettingsCategory[] = SETTINGS_CATEGORY_GROUPS.flatMap(group => group.categories);
|
|
53
|
+
|
|
72
54
|
export interface SettingEntry {
|
|
73
55
|
setting: ConfigSetting;
|
|
74
56
|
currentValue: unknown;
|
|
@@ -34,6 +34,7 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
34
34
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
35
35
|
import {
|
|
36
36
|
SETTINGS_CATEGORIES,
|
|
37
|
+
SETTINGS_CATEGORY_GROUPS,
|
|
37
38
|
type FlagEntry,
|
|
38
39
|
type McpEntry,
|
|
39
40
|
type SettingEntry,
|
|
@@ -42,8 +43,25 @@ import {
|
|
|
42
43
|
type SubscriptionEntry,
|
|
43
44
|
} from './settings-modal-types.ts';
|
|
44
45
|
|
|
46
|
+
export interface SettingsModalChange {
|
|
47
|
+
readonly key: ConfigKey;
|
|
48
|
+
readonly previousValue: unknown;
|
|
49
|
+
readonly value: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SettingsModalChangeResult {
|
|
53
|
+
readonly message?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SettingsModalChangeHandler = (change: SettingsModalChange) => SettingsModalChangeResult | void;
|
|
57
|
+
|
|
58
|
+
export interface SettingsModalOpenOptions {
|
|
59
|
+
readonly onSettingApplied?: SettingsModalChangeHandler;
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
export {
|
|
46
63
|
SETTINGS_CATEGORIES,
|
|
64
|
+
SETTINGS_CATEGORY_GROUPS,
|
|
47
65
|
type FlagEntry,
|
|
48
66
|
type McpEntry,
|
|
49
67
|
type SettingEntry,
|
|
@@ -104,6 +122,7 @@ export class SettingsModal {
|
|
|
104
122
|
* Cleared on next open() or close().
|
|
105
123
|
*/
|
|
106
124
|
public lastSaveTriggeredRestart: 'control-plane' | 'http-listener' | 'web' | null = null;
|
|
125
|
+
public lastSettingEffectMessage: string | null = null;
|
|
107
126
|
|
|
108
127
|
private configManager: ConfigManager | null = null;
|
|
109
128
|
private secretsManager: SettingsSecretsManager | null = null;
|
|
@@ -111,6 +130,7 @@ export class SettingsModal {
|
|
|
111
130
|
private mcpRegistry: McpRegistry | null = null;
|
|
112
131
|
private subscriptionManager: SubscriptionManager | null = null;
|
|
113
132
|
private serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'> | null = null;
|
|
133
|
+
private onSettingApplied: SettingsModalChangeHandler | null = null;
|
|
114
134
|
|
|
115
135
|
/**
|
|
116
136
|
* Open the modal, loading current config values from configManager.
|
|
@@ -125,6 +145,7 @@ export class SettingsModal {
|
|
|
125
145
|
serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
|
|
126
146
|
mcpRegistry?: McpRegistry,
|
|
127
147
|
secretsManager?: SettingsSecretsManager,
|
|
148
|
+
options?: SettingsModalOpenOptions,
|
|
128
149
|
): void {
|
|
129
150
|
this.configManager = configManager;
|
|
130
151
|
this.secretsManager = secretsManager ?? null;
|
|
@@ -132,13 +153,14 @@ export class SettingsModal {
|
|
|
132
153
|
this.subscriptionManager = subscriptionManager;
|
|
133
154
|
this.serviceRegistry = serviceRegistry;
|
|
134
155
|
this.mcpRegistry = mcpRegistry ?? null;
|
|
156
|
+
this.onSettingApplied = options?.onSettingApplied ?? null;
|
|
135
157
|
this._loadGroups(configManager);
|
|
136
158
|
this._loadFlagEntries();
|
|
137
159
|
this._loadMcpEntries();
|
|
138
160
|
this._loadSubscriptionEntries();
|
|
139
161
|
this.categoryIndex = 0;
|
|
140
162
|
this.selectedIndex = 0;
|
|
141
|
-
this.focusPane = '
|
|
163
|
+
this.focusPane = 'categories';
|
|
142
164
|
this.editingMode = false;
|
|
143
165
|
this.editBuffer = '';
|
|
144
166
|
this.pendingModelPickerTarget = null;
|
|
@@ -147,6 +169,7 @@ export class SettingsModal {
|
|
|
147
169
|
this.mcpAllowAllConfirmationTarget = null;
|
|
148
170
|
this.subscriptionLogoutConfirmationTarget = null;
|
|
149
171
|
this.lastSaveTriggeredRestart = null;
|
|
172
|
+
this.lastSettingEffectMessage = null;
|
|
150
173
|
this.active = true;
|
|
151
174
|
}
|
|
152
175
|
|
|
@@ -160,8 +183,10 @@ export class SettingsModal {
|
|
|
160
183
|
this.mcpAllowAllConfirmationTarget = null;
|
|
161
184
|
this.subscriptionLogoutConfirmationTarget = null;
|
|
162
185
|
this.lastSaveTriggeredRestart = null;
|
|
186
|
+
this.lastSettingEffectMessage = null;
|
|
163
187
|
this.serviceRegistry = null;
|
|
164
188
|
this.secretsManager = null;
|
|
189
|
+
this.onSettingApplied = null;
|
|
165
190
|
this.focusPane = 'settings';
|
|
166
191
|
}
|
|
167
192
|
|
|
@@ -715,6 +740,16 @@ export class SettingsModal {
|
|
|
715
740
|
return items;
|
|
716
741
|
}
|
|
717
742
|
|
|
743
|
+
private _refreshAllEntries(): void {
|
|
744
|
+
if (!this.configManager) return;
|
|
745
|
+
for (const entries of this.groups.values()) {
|
|
746
|
+
for (const entry of entries) {
|
|
747
|
+
entry.currentValue = this.configManager.get(entry.setting.key as ConfigKey);
|
|
748
|
+
entry.isDefault = entry.currentValue === entry.setting.default;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
718
753
|
private _setValue(key: ConfigKey, value: unknown): void {
|
|
719
754
|
if (!this.configManager) return;
|
|
720
755
|
// Diff previous value before writing — avoids false restart notices on no-op saves
|
|
@@ -744,8 +779,14 @@ export class SettingsModal {
|
|
|
744
779
|
entry.isDefault = entry.currentValue === entry.setting.default;
|
|
745
780
|
}
|
|
746
781
|
}
|
|
782
|
+
if (previousValue !== value && this.onSettingApplied) {
|
|
783
|
+
const result = this.onSettingApplied({ key, previousValue, value });
|
|
784
|
+
this.lastSettingEffectMessage = result?.message ?? null;
|
|
785
|
+
this._refreshAllEntries();
|
|
786
|
+
}
|
|
747
787
|
} catch (e) {
|
|
748
788
|
logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
|
|
789
|
+
this.lastSettingEffectMessage = `Save failed: ${summarizeError(e)}`;
|
|
749
790
|
}
|
|
750
791
|
}
|
|
751
792
|
|
package/src/main.ts
CHANGED
|
@@ -678,6 +678,8 @@ async function main() {
|
|
|
678
678
|
subscriptionManager,
|
|
679
679
|
secretsManager,
|
|
680
680
|
serviceRegistry: ctx.services.serviceRegistry,
|
|
681
|
+
workingDirectory: workingDir,
|
|
682
|
+
homeDirectory,
|
|
681
683
|
getConfiguredProviderIds: ctx._getConfiguredProviderIds,
|
|
682
684
|
getPinned: ctx._getPinned,
|
|
683
685
|
render,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { Line } from '../types/grid.ts';
|
|
9
9
|
import { createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
10
10
|
import type { SettingsModal, SettingEntry, FlagEntry, McpEntry, SubscriptionEntry, SettingsCategory } from '../input/settings-modal.ts';
|
|
11
|
-
import { SETTINGS_CATEGORIES } from '../input/settings-modal.ts';
|
|
11
|
+
import { SETTINGS_CATEGORIES, SETTINGS_CATEGORY_GROUPS } from '../input/settings-modal.ts';
|
|
12
12
|
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
13
13
|
import { CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
|
|
14
14
|
import { isSecretConfigKey } from '../config/secret-config.ts';
|
|
@@ -413,18 +413,46 @@ function categoryItemCount(modal: SettingsModal, category: SettingsCategory): nu
|
|
|
413
413
|
return modal.groups.get(category)?.length ?? 0;
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
type CategoryRailEntry =
|
|
417
|
+
| { readonly type: 'group'; readonly label: string }
|
|
418
|
+
| { readonly type: 'category'; readonly category: SettingsCategory; readonly index: number };
|
|
419
|
+
|
|
420
|
+
function buildCategoryRailEntries(): CategoryRailEntry[] {
|
|
421
|
+
const entries: CategoryRailEntry[] = [];
|
|
422
|
+
for (const group of SETTINGS_CATEGORY_GROUPS) {
|
|
423
|
+
const categories = group.categories.filter(category => SETTINGS_CATEGORIES.includes(category));
|
|
424
|
+
if (categories.length === 0) continue;
|
|
425
|
+
entries.push({ type: 'group', label: group.label });
|
|
426
|
+
for (const category of categories) {
|
|
427
|
+
entries.push({
|
|
428
|
+
type: 'category',
|
|
429
|
+
category,
|
|
430
|
+
index: SETTINGS_CATEGORIES.indexOf(category),
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return entries;
|
|
435
|
+
}
|
|
436
|
+
|
|
416
437
|
function renderCategories(modal: SettingsModal, width: number, height: number): string[] {
|
|
417
438
|
const rows: string[] = [];
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
439
|
+
const entries = buildCategoryRailEntries();
|
|
440
|
+
const selectedEntryIndex = Math.max(0, entries.findIndex(entry => entry.type === 'category' && entry.index === modal.categoryIndex));
|
|
441
|
+
const window = stableWindow(entries.length, selectedEntryIndex, height);
|
|
442
|
+
if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more row(s) above`);
|
|
443
|
+
for (let railIndex = window.start; railIndex < window.end; railIndex += 1) {
|
|
444
|
+
const entry = entries[railIndex]!;
|
|
445
|
+
if (entry.type === 'group') {
|
|
446
|
+
rows.push(` ${entry.label.toUpperCase()}`);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const category = entry.category;
|
|
450
|
+
const active = entry.index === modal.categoryIndex;
|
|
423
451
|
const count = categoryItemCount(modal, category);
|
|
424
452
|
const cursor = active ? (modal.focusPane === 'categories' ? GLYPHS.navigation.selected : '•') : ' ';
|
|
425
453
|
rows.push(`${cursor} ${CATEGORY_LABELS[category]} (${count})`);
|
|
426
454
|
}
|
|
427
|
-
if (window.end <
|
|
455
|
+
if (window.end < entries.length) rows.push(`${GLYPHS.navigation.moreBelow} ${entries.length - window.end} more row(s) below`);
|
|
428
456
|
while (rows.length < height) rows.push('');
|
|
429
457
|
return rows.slice(0, height);
|
|
430
458
|
}
|
|
@@ -590,7 +618,11 @@ export function renderSettingsModal(
|
|
|
590
618
|
const header = contentLine(safeWidth, PALETTE.footerBg);
|
|
591
619
|
drawVertical(header, dividerX, PALETTE.footerBg);
|
|
592
620
|
writeText(header, leftStart + 1, leftWidth - 2, 'Categories', { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
|
|
593
|
-
const
|
|
621
|
+
const notices = [
|
|
622
|
+
...(modal.lastSaveTriggeredRestart ? [`Restarting ${modal.lastSaveTriggeredRestart}`] : []),
|
|
623
|
+
...(modal.lastSettingEffectMessage ? [modal.lastSettingEffectMessage] : []),
|
|
624
|
+
];
|
|
625
|
+
const headerText = `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`;
|
|
594
626
|
writeText(header, centerStart + 1, centerWidth - 2, headerText, { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
|
|
595
627
|
lines.push(header);
|
|
596
628
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
4
|
+
import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
|
|
5
|
+
import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
6
|
+
import {
|
|
7
|
+
createPlatformServiceManager,
|
|
8
|
+
getServiceStateRoot,
|
|
9
|
+
type CliServiceRuntime,
|
|
10
|
+
} from '../cli/service-posture.ts';
|
|
11
|
+
|
|
12
|
+
type ManagedServiceAction = 'install' | 'uninstall' | 'start' | 'stop' | 'restart' | 'status';
|
|
13
|
+
|
|
14
|
+
export interface ServiceManagerLike {
|
|
15
|
+
status(): ManagedServiceStatus;
|
|
16
|
+
install(): ManagedServiceStatus;
|
|
17
|
+
uninstall(): ManagedServiceStatus;
|
|
18
|
+
start(): ManagedServiceStatus;
|
|
19
|
+
stop(): ManagedServiceStatus;
|
|
20
|
+
restart(): ManagedServiceStatus;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CommandResult {
|
|
24
|
+
readonly status: number | null;
|
|
25
|
+
readonly stdout?: string;
|
|
26
|
+
readonly stderr?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ServiceSettingsSyncChange {
|
|
30
|
+
readonly key: ConfigKey;
|
|
31
|
+
readonly previousValue: unknown;
|
|
32
|
+
readonly value: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ServiceSettingsSyncResult {
|
|
36
|
+
readonly handled: boolean;
|
|
37
|
+
readonly action?: ManagedServiceAction | 'install-start' | 'disable';
|
|
38
|
+
readonly status?: ManagedServiceStatus;
|
|
39
|
+
readonly message?: string;
|
|
40
|
+
readonly error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ServiceSettingsSyncOptions {
|
|
44
|
+
readonly createManager?: (runtime: CliServiceRuntime) => ServiceManagerLike;
|
|
45
|
+
readonly runCommand?: (command: string, args: readonly string[]) => CommandResult;
|
|
46
|
+
readonly mkdir?: typeof mkdirSync;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SERVICE_DEFINITION_KEYS = new Set<ConfigKey>([
|
|
50
|
+
'service.restartOnFailure',
|
|
51
|
+
'service.platform',
|
|
52
|
+
'service.serviceName',
|
|
53
|
+
'service.logPath',
|
|
54
|
+
] as ConfigKey[]);
|
|
55
|
+
|
|
56
|
+
function runCommand(command: string, args: readonly string[], options: ServiceSettingsSyncOptions): CommandResult {
|
|
57
|
+
if (options.runCommand) return options.runCommand(command, args);
|
|
58
|
+
return spawnSync(command, [...args], { stdio: 'pipe', encoding: 'utf-8' });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function commandError(result: CommandResult): string | null {
|
|
62
|
+
if ((result.status ?? 1) === 0) return null;
|
|
63
|
+
return ((result.stderr ?? '') || (result.stdout ?? '') || `command exited with ${result.status}`).trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function serviceName(runtime: CliServiceRuntime, fallback = 'goodvibes'): string {
|
|
67
|
+
return String(runtime.configManager.get('service.serviceName') ?? fallback).trim() || fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function runSystemd(runtime: CliServiceRuntime, args: readonly string[], options: ServiceSettingsSyncOptions): string | null {
|
|
71
|
+
const result = runCommand('systemctl', ['--user', ...args], options);
|
|
72
|
+
return commandError(result);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function reloadSystemdIfNeeded(
|
|
76
|
+
runtime: CliServiceRuntime,
|
|
77
|
+
status: ManagedServiceStatus,
|
|
78
|
+
options: ServiceSettingsSyncOptions,
|
|
79
|
+
): string | null {
|
|
80
|
+
if (status.platform !== 'systemd') return null;
|
|
81
|
+
return runSystemd(runtime, ['daemon-reload'], options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function disableSystemService(
|
|
85
|
+
runtime: CliServiceRuntime,
|
|
86
|
+
manager: ServiceManagerLike,
|
|
87
|
+
options: ServiceSettingsSyncOptions,
|
|
88
|
+
): ServiceSettingsSyncResult {
|
|
89
|
+
const before = manager.status();
|
|
90
|
+
let disableError: string | null = null;
|
|
91
|
+
if (before.platform === 'systemd') {
|
|
92
|
+
disableError = before.installed || before.running
|
|
93
|
+
? runSystemd(runtime, ['disable', '--now', `${serviceName(runtime)}.service`], options)
|
|
94
|
+
: null;
|
|
95
|
+
if (disableError) {
|
|
96
|
+
logger.warn('Settings service sync: systemd disable failed', { error: disableError });
|
|
97
|
+
}
|
|
98
|
+
} else if (before.running || before.installed) {
|
|
99
|
+
const stopped = manager.stop();
|
|
100
|
+
if (stopped.actionError) {
|
|
101
|
+
return {
|
|
102
|
+
handled: true,
|
|
103
|
+
action: 'stop',
|
|
104
|
+
status: stopped,
|
|
105
|
+
message: `Service disable failed: ${stopped.actionError}`,
|
|
106
|
+
error: stopped.actionError,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const uninstalled = manager.uninstall();
|
|
112
|
+
const reloadError = reloadSystemdIfNeeded(runtime, uninstalled, options);
|
|
113
|
+
const error = uninstalled.actionError ?? reloadError ?? disableError ?? undefined;
|
|
114
|
+
return {
|
|
115
|
+
handled: true,
|
|
116
|
+
action: 'disable',
|
|
117
|
+
status: uninstalled,
|
|
118
|
+
message: error ? `Service disable failed: ${error}` : 'OS service disabled',
|
|
119
|
+
...(error ? { error } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function installAndStartSystemService(
|
|
124
|
+
runtime: CliServiceRuntime,
|
|
125
|
+
manager: ServiceManagerLike,
|
|
126
|
+
options: ServiceSettingsSyncOptions,
|
|
127
|
+
): ServiceSettingsSyncResult {
|
|
128
|
+
(options.mkdir ?? mkdirSync)(getServiceStateRoot(runtime), { recursive: true });
|
|
129
|
+
const installed = manager.install();
|
|
130
|
+
if (installed.actionError) {
|
|
131
|
+
return {
|
|
132
|
+
handled: true,
|
|
133
|
+
action: 'install',
|
|
134
|
+
status: installed,
|
|
135
|
+
message: `Service install failed: ${installed.actionError}`,
|
|
136
|
+
error: installed.actionError,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reloadError = reloadSystemdIfNeeded(runtime, installed, options);
|
|
141
|
+
if (reloadError) {
|
|
142
|
+
return {
|
|
143
|
+
handled: true,
|
|
144
|
+
action: 'install',
|
|
145
|
+
status: installed,
|
|
146
|
+
message: `Service install failed: ${reloadError}`,
|
|
147
|
+
error: reloadError,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const started = manager.start();
|
|
152
|
+
const error = started.actionError ?? undefined;
|
|
153
|
+
return {
|
|
154
|
+
handled: true,
|
|
155
|
+
action: 'install-start',
|
|
156
|
+
status: started,
|
|
157
|
+
message: error ? `Service start failed: ${error}` : 'OS service installed and started',
|
|
158
|
+
...(error ? { error } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function refreshInstalledSystemService(
|
|
163
|
+
runtime: CliServiceRuntime,
|
|
164
|
+
manager: ServiceManagerLike,
|
|
165
|
+
options: ServiceSettingsSyncOptions,
|
|
166
|
+
): ServiceSettingsSyncResult {
|
|
167
|
+
const before = manager.status();
|
|
168
|
+
if (!before.installed && runtime.configManager.get('service.autostart') !== true) {
|
|
169
|
+
return {
|
|
170
|
+
handled: true,
|
|
171
|
+
action: 'status',
|
|
172
|
+
status: before,
|
|
173
|
+
message: 'Service setting saved',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const installed = manager.install();
|
|
178
|
+
if (installed.actionError) {
|
|
179
|
+
return {
|
|
180
|
+
handled: true,
|
|
181
|
+
action: 'install',
|
|
182
|
+
status: installed,
|
|
183
|
+
message: `Service update failed: ${installed.actionError}`,
|
|
184
|
+
error: installed.actionError,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const reloadError = reloadSystemdIfNeeded(runtime, installed, options);
|
|
189
|
+
if (reloadError) {
|
|
190
|
+
return {
|
|
191
|
+
handled: true,
|
|
192
|
+
action: 'install',
|
|
193
|
+
status: installed,
|
|
194
|
+
message: `Service update failed: ${reloadError}`,
|
|
195
|
+
error: reloadError,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const next = before.running ? manager.restart() : manager.start();
|
|
200
|
+
const error = next.actionError ?? undefined;
|
|
201
|
+
return {
|
|
202
|
+
handled: true,
|
|
203
|
+
action: before.running ? 'restart' : 'start',
|
|
204
|
+
status: next,
|
|
205
|
+
message: error ? `Service update failed: ${error}` : 'OS service updated',
|
|
206
|
+
...(error ? { error } : {}),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function syncServiceSettingToPlatform(
|
|
211
|
+
runtime: CliServiceRuntime,
|
|
212
|
+
change: ServiceSettingsSyncChange,
|
|
213
|
+
options: ServiceSettingsSyncOptions = {},
|
|
214
|
+
): ServiceSettingsSyncResult {
|
|
215
|
+
if (!String(change.key).startsWith('service.')) return { handled: false };
|
|
216
|
+
if (change.previousValue === change.value) return { handled: true, message: 'Service setting unchanged' };
|
|
217
|
+
|
|
218
|
+
const manager = options.createManager?.(runtime) ?? createPlatformServiceManager(runtime);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
if (change.key === 'service.autostart') {
|
|
222
|
+
if (change.value === true) {
|
|
223
|
+
if (runtime.configManager.get('service.enabled') !== true) {
|
|
224
|
+
runtime.configManager.setDynamic('service.enabled', true);
|
|
225
|
+
}
|
|
226
|
+
return installAndStartSystemService(runtime, manager, options);
|
|
227
|
+
}
|
|
228
|
+
return disableSystemService(runtime, manager, options);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (change.key === 'service.enabled') {
|
|
232
|
+
if (change.value === false) {
|
|
233
|
+
if (runtime.configManager.get('service.autostart') === true) {
|
|
234
|
+
runtime.configManager.setDynamic('service.autostart', false);
|
|
235
|
+
}
|
|
236
|
+
return disableSystemService(runtime, manager, options);
|
|
237
|
+
}
|
|
238
|
+
if (runtime.configManager.get('service.autostart') === true) {
|
|
239
|
+
return installAndStartSystemService(runtime, manager, options);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
handled: true,
|
|
243
|
+
action: 'status',
|
|
244
|
+
status: manager.status(),
|
|
245
|
+
message: 'Service mode saved; enable autostart to install the OS service',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (SERVICE_DEFINITION_KEYS.has(change.key)) {
|
|
250
|
+
if (runtime.configManager.get('service.enabled') === true && runtime.configManager.get('service.autostart') === true) {
|
|
251
|
+
return refreshInstalledSystemService(runtime, manager, options);
|
|
252
|
+
}
|
|
253
|
+
return {
|
|
254
|
+
handled: true,
|
|
255
|
+
action: 'status',
|
|
256
|
+
status: manager.status(),
|
|
257
|
+
message: 'Service setting saved; enable autostart to install the OS service',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const summarized = summarizeError(error);
|
|
262
|
+
logger.error('Settings service sync failed', { key: change.key, error: summarized });
|
|
263
|
+
return {
|
|
264
|
+
handled: true,
|
|
265
|
+
action: 'status',
|
|
266
|
+
status: manager.status(),
|
|
267
|
+
message: `Service sync failed: ${summarized}`,
|
|
268
|
+
error: summarized,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { handled: false };
|
|
273
|
+
}
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config'
|
|
|
12
12
|
import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
13
13
|
import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
|
|
14
14
|
import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
|
|
15
|
+
import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
|
|
15
16
|
|
|
16
17
|
type WireShellUiOpenersOptions = {
|
|
17
18
|
commandContext: CommandContext;
|
|
@@ -26,6 +27,8 @@ type WireShellUiOpenersOptions = {
|
|
|
26
27
|
subscriptionManager: SubscriptionManager;
|
|
27
28
|
secretsManager?: Pick<SecretsManager, 'delete' | 'get' | 'set'>;
|
|
28
29
|
serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
|
|
30
|
+
workingDirectory: string;
|
|
31
|
+
homeDirectory: string;
|
|
29
32
|
getConfiguredProviderIds: () => string[];
|
|
30
33
|
getPinned: () => Promise<string[]>;
|
|
31
34
|
render: () => void;
|
|
@@ -89,6 +92,8 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
89
92
|
subscriptionManager,
|
|
90
93
|
secretsManager,
|
|
91
94
|
serviceRegistry,
|
|
95
|
+
workingDirectory,
|
|
96
|
+
homeDirectory,
|
|
92
97
|
getConfiguredProviderIds,
|
|
93
98
|
getPinned,
|
|
94
99
|
render,
|
|
@@ -264,7 +269,12 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
|
|
|
264
269
|
|
|
265
270
|
commandContext.openSettingsModal = (target?: string) => {
|
|
266
271
|
input.modalOpened('settings');
|
|
267
|
-
input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager
|
|
272
|
+
input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager, {
|
|
273
|
+
onSettingApplied: (change) => syncServiceSettingToPlatform(
|
|
274
|
+
{ configManager, workingDirectory, homeDirectory },
|
|
275
|
+
change,
|
|
276
|
+
),
|
|
277
|
+
});
|
|
268
278
|
input.settingsModal.selectTarget(target);
|
|
269
279
|
render();
|
|
270
280
|
};
|
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.66';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|