@pellux/goodvibes-tui 0.19.25 → 0.19.26

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/src/cli/status.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config/manager';
2
2
  import type { OnboardingCompletionMarkersState } from '../runtime/onboarding/index.ts';
3
3
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
4
+ import { isNetworkFacing } from './network-posture.ts';
5
+ import type { GoodVibesCliOutputFormat } from './types.ts';
6
+ import type { CliServicePosture } from './service-posture.ts';
4
7
 
5
8
  export interface CliStatusOptions {
6
9
  readonly configManager: Pick<ConfigManager, 'get'>;
@@ -8,7 +11,9 @@ export interface CliStatusOptions {
8
11
  readonly homeDirectory: string;
9
12
  readonly onboardingMarkers?: OnboardingCompletionMarkersState;
10
13
  readonly auth?: CliAuthStatus;
14
+ readonly service?: CliServicePosture;
11
15
  readonly doctor?: boolean;
16
+ readonly outputFormat?: GoodVibesCliOutputFormat;
12
17
  }
13
18
 
14
19
  export interface CliAuthStatus {
@@ -20,38 +25,285 @@ export interface CliAuthStatus {
20
25
  readonly operatorTokenPresent: boolean;
21
26
  }
22
27
 
28
+ export interface CliDoctorFinding {
29
+ readonly id: string;
30
+ readonly area: 'auth' | 'network' | 'onboarding' | 'security' | 'service' | 'secrets';
31
+ readonly severity: 'warning' | 'risk';
32
+ readonly summary: string;
33
+ readonly cause: string;
34
+ readonly impact: string;
35
+ readonly action: string;
36
+ }
37
+
38
+ export interface CliStatusSnapshot {
39
+ readonly title: 'GoodVibes status' | 'GoodVibes doctor';
40
+ readonly workingDirectory: string;
41
+ readonly homeDirectory: string;
42
+ readonly provider: {
43
+ readonly provider: string;
44
+ readonly model: string;
45
+ readonly reasoning: string;
46
+ };
47
+ readonly auth: {
48
+ readonly permissionMode: unknown;
49
+ readonly permissionLabel: string;
50
+ readonly secretPolicy: unknown;
51
+ readonly secretPolicyLabel: string;
52
+ readonly localUsers: CliAuthStatus | null;
53
+ };
54
+ readonly service: {
55
+ readonly enabled: unknown;
56
+ readonly autostart: unknown;
57
+ readonly restartOnFailure: unknown;
58
+ readonly lifecycle?: CliServicePosture;
59
+ };
60
+ readonly surfaces: {
61
+ readonly controlPlane: ReturnType<typeof resolveRuntimeEndpointBinding> & { readonly enabled: unknown };
62
+ readonly httpListener: ReturnType<typeof resolveRuntimeEndpointBinding> & { readonly enabled: unknown };
63
+ readonly web: ReturnType<typeof resolveRuntimeEndpointBinding> & { readonly enabled: unknown };
64
+ };
65
+ readonly onboarding: {
66
+ readonly completed: boolean;
67
+ readonly scope: string;
68
+ readonly updatedAt: number | null;
69
+ };
70
+ readonly findings: readonly CliDoctorFinding[];
71
+ }
72
+
23
73
  function yesNo(value: unknown): string {
24
74
  return value === true ? 'yes' : 'no';
25
75
  }
26
76
 
77
+ function permissionModeLabel(mode: unknown): string {
78
+ if (mode === 'prompt') return 'Ask before powerful actions';
79
+ if (mode === 'allow-all') return 'Allow everything';
80
+ if (mode === 'custom') return 'Custom rules';
81
+ return String(mode ?? 'unknown');
82
+ }
83
+
84
+ function secretPolicyLabel(policy: unknown): string {
85
+ if (policy === 'preferred_secure') return 'Use secure storage when available';
86
+ if (policy === 'require_secure') return 'Require secure storage';
87
+ if (policy === 'plaintext_allowed') return 'Allow plaintext storage';
88
+ return String(policy ?? 'unknown');
89
+ }
90
+
27
91
  function bindLine(label: string, enabled: unknown, binding: { readonly hostMode: string; readonly host: string; readonly port: number }): string {
28
92
  return ` ${label}: ${yesNo(enabled)} (${binding.hostMode} ${binding.host}:${binding.port})`;
29
93
  }
30
94
 
31
- export function renderCliStatus(options: CliStatusOptions): string {
95
+ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliDoctorFinding[] {
32
96
  const config = options.configManager;
33
- const serviceEnabled = config.get('service.enabled');
34
- const serviceAutostart = config.get('service.autostart');
35
- const restartOnFailure = config.get('service.restartOnFailure');
36
- const daemonEnabled = config.get('danger.daemon');
37
- const listenerEnabled = config.get('danger.httpListener');
38
- const webEnabled = config.get('web.enabled');
39
- const controlPlaneEnabled = config.get('controlPlane.enabled');
97
+ const serviceEnabled = config.get('service.enabled') === true;
98
+ const serviceAutostart = config.get('service.autostart') === true;
99
+ const restartOnFailure = config.get('service.restartOnFailure') === true;
100
+ const daemonEnabled = config.get('danger.daemon') === true;
101
+ const listenerEnabled = config.get('danger.httpListener') === true;
102
+ const webEnabled = config.get('web.enabled') === true;
103
+ const controlPlaneEnabled = config.get('controlPlane.enabled') === true;
40
104
  const controlPlaneBinding = resolveRuntimeEndpointBinding(config, 'controlPlane');
41
105
  const httpListenerBinding = resolveRuntimeEndpointBinding(config, 'httpListener');
42
106
  const webBinding = resolveRuntimeEndpointBinding(config, 'web');
107
+ const permissionMode = config.get('permissions.mode');
108
+ const secretPolicy = config.get('storage.secretPolicy');
43
109
  const marker = options.onboardingMarkers?.effective;
44
- const warnings: string[] = [];
110
+ const serverBackedEnabled = daemonEnabled || controlPlaneEnabled || listenerEnabled || webEnabled;
111
+ const networkFacingSurfaces = [
112
+ ['control plane', controlPlaneEnabled, controlPlaneBinding],
113
+ ['HTTP listener', listenerEnabled, httpListenerBinding],
114
+ ['web surface', webEnabled, webBinding],
115
+ ].filter(([, enabled, binding]) => isNetworkFacing(enabled, binding as typeof controlPlaneBinding));
116
+
117
+ const findings: CliDoctorFinding[] = [];
118
+
119
+ if (serverBackedEnabled && !serviceEnabled) {
120
+ findings.push({
121
+ id: 'service-disabled-for-server-surfaces',
122
+ area: 'service',
123
+ severity: 'warning',
124
+ summary: 'Server-backed surfaces are enabled but service mode is off.',
125
+ cause: 'One or more daemon, control-plane, listener, or web settings are enabled while service.enabled is false.',
126
+ impact: 'The configured surfaces may not start automatically or survive restarts.',
127
+ action: 'Enable service mode or disable the server-backed surfaces you do not want.',
128
+ });
129
+ }
130
+
131
+ if (serviceEnabled && !serviceAutostart) {
132
+ findings.push({
133
+ id: 'service-autostart-disabled',
134
+ area: 'service',
135
+ severity: 'warning',
136
+ summary: 'Service mode is enabled but autostart is off.',
137
+ cause: 'service.enabled is true and service.autostart is false.',
138
+ impact: 'GoodVibes may not start after login or reboot even though service mode is selected.',
139
+ action: 'Enable service.autostart if the daemon/listener/web surfaces should stay available.',
140
+ });
141
+ }
142
+
143
+ if (serviceEnabled && !restartOnFailure) {
144
+ findings.push({
145
+ id: 'service-restart-disabled',
146
+ area: 'service',
147
+ severity: 'warning',
148
+ summary: 'Service restart-on-failure is off.',
149
+ cause: 'service.enabled is true and service.restartOnFailure is false.',
150
+ impact: 'A crashed daemon or listener may stay down until manually restarted.',
151
+ action: 'Enable service.restartOnFailure for durable daemon/listener operation.',
152
+ });
153
+ }
154
+
155
+ if (options.service) {
156
+ for (const issue of options.service.issues) {
157
+ if (findings.some((finding) => finding.summary === issue)) continue;
158
+ findings.push({
159
+ id: `service-lifecycle-${findings.length}`,
160
+ area: 'service',
161
+ severity: 'warning',
162
+ summary: issue,
163
+ cause: 'The service lifecycle inspection found a mismatch between configured service/surface state and observed host state.',
164
+ impact: 'Daemon, control-plane, listener, or web availability may not match the configuration.',
165
+ action: 'Run goodvibes service check and apply the suggested service install/start/configuration fix.',
166
+ });
167
+ }
168
+ }
169
+
170
+ if (!marker?.payload) {
171
+ findings.push({
172
+ id: 'onboarding-incomplete',
173
+ area: 'onboarding',
174
+ severity: 'warning',
175
+ summary: 'Onboarding has not been completed for this user/project.',
176
+ cause: 'No effective onboarding completion marker was found.',
177
+ impact: 'Important service, network, provider, auth, or permission choices may still be implicit defaults.',
178
+ action: 'Run /onboarding in the TUI or goodvibes onboarding status to review setup state.',
179
+ });
180
+ }
45
181
 
46
- if ((daemonEnabled || controlPlaneEnabled || listenerEnabled || webEnabled) && !serviceEnabled) {
47
- warnings.push('server-backed surfaces are enabled but service.enabled is off');
182
+ if (networkFacingSurfaces.length > 0 && options.auth?.userStorePresent !== true) {
183
+ findings.push({
184
+ id: 'network-surface-without-local-users',
185
+ area: 'auth',
186
+ severity: 'risk',
187
+ summary: 'Network-facing surfaces are enabled before local users are configured.',
188
+ cause: `${networkFacingSurfaces.map(([name]) => name).join(', ')} are LAN/custom-bound, but no local auth user store was found.`,
189
+ impact: 'Remote access paths may be unusable or unsafe until local admin auth is configured.',
190
+ action: 'Create/verify a local admin user before exposing GoodVibes on the network.',
191
+ });
48
192
  }
49
- if (serviceEnabled && !serviceAutostart) warnings.push('service.enabled is on but service.autostart is off');
50
- if (serviceEnabled && !restartOnFailure) warnings.push('service.enabled is on but service.restartOnFailure is off');
51
- if (!marker?.payload) warnings.push('onboarding has not been completed for this user/project');
193
+
194
+ if (networkFacingSurfaces.length > 0 && options.auth?.bootstrapCredentialPresent === true) {
195
+ findings.push({
196
+ id: 'network-surface-with-bootstrap-credential',
197
+ area: 'auth',
198
+ severity: 'risk',
199
+ summary: 'A bootstrap credential is still present while network-facing surfaces are enabled.',
200
+ cause: `${networkFacingSurfaces.map(([name]) => name).join(', ')} are LAN/custom-bound and auth-bootstrap.txt exists.`,
201
+ impact: 'Bootstrap credentials should be treated as temporary setup material, not long-lived network access credentials.',
202
+ action: 'Replace bootstrap auth with a named admin user and retire the bootstrap credential.',
203
+ });
204
+ }
205
+
206
+ if (permissionMode === 'allow-all') {
207
+ findings.push({
208
+ id: 'allow-all-permissions',
209
+ area: 'security',
210
+ severity: 'risk',
211
+ summary: 'Allow everything permission mode is active.',
212
+ cause: 'permissions.mode is allow-all.',
213
+ impact: 'Powerful write, edit, network, and execution tools can run without a Human-in-the-Loop (HITL) approval prompt.',
214
+ action: 'Use Ask before powerful actions or Custom rules unless this is an intentionally trusted environment.',
215
+ });
216
+ }
217
+
218
+ if (secretPolicy === 'plaintext_allowed') {
219
+ findings.push({
220
+ id: 'plaintext-secrets-allowed',
221
+ area: 'secrets',
222
+ severity: 'risk',
223
+ summary: 'Plaintext secret storage is allowed.',
224
+ cause: 'storage.secretPolicy is plaintext_allowed.',
225
+ impact: 'Provider keys and surface tokens may be stored without secure backend protection.',
226
+ action: 'Use Require secure storage or Use secure storage when available for normal operation.',
227
+ });
228
+ }
229
+
230
+ if (listenerEnabled && isNetworkFacing(listenerEnabled, httpListenerBinding)) {
231
+ findings.push({
232
+ id: 'network-http-listener-enabled',
233
+ area: 'network',
234
+ severity: 'warning',
235
+ summary: 'The HTTP listener is reachable beyond loopback.',
236
+ cause: `HTTP listener is enabled on ${httpListenerBinding.host}:${httpListenerBinding.port} with ${httpListenerBinding.hostMode} binding.`,
237
+ impact: 'External tools and devices may be able to reach incoming event endpoints.',
238
+ action: 'Keep listener secrets/signature checks configured for every enabled webhook surface.',
239
+ });
240
+ }
241
+
242
+ return findings;
243
+ }
244
+
245
+ export function buildCliStatusSnapshot(options: CliStatusOptions): CliStatusSnapshot {
246
+ const config = options.configManager;
247
+ const controlPlaneBinding = resolveRuntimeEndpointBinding(config, 'controlPlane');
248
+ const httpListenerBinding = resolveRuntimeEndpointBinding(config, 'httpListener');
249
+ const webBinding = resolveRuntimeEndpointBinding(config, 'web');
250
+ const marker = options.onboardingMarkers?.effective;
251
+ const findings = buildCliDoctorFindings(options);
252
+ return {
253
+ title: options.doctor ? 'GoodVibes doctor' : 'GoodVibes status',
254
+ workingDirectory: options.workingDirectory,
255
+ homeDirectory: options.homeDirectory,
256
+ provider: {
257
+ provider: String(config.get('provider.provider')),
258
+ model: String(config.get('provider.model')),
259
+ reasoning: String(config.get('provider.reasoningEffort')),
260
+ },
261
+ auth: {
262
+ permissionMode: config.get('permissions.mode'),
263
+ permissionLabel: permissionModeLabel(config.get('permissions.mode')),
264
+ secretPolicy: config.get('storage.secretPolicy'),
265
+ secretPolicyLabel: secretPolicyLabel(config.get('storage.secretPolicy')),
266
+ localUsers: options.auth ?? null,
267
+ },
268
+ service: {
269
+ enabled: config.get('service.enabled'),
270
+ autostart: config.get('service.autostart'),
271
+ restartOnFailure: config.get('service.restartOnFailure'),
272
+ ...(options.service ? { lifecycle: options.service } : {}),
273
+ },
274
+ surfaces: {
275
+ controlPlane: { enabled: config.get('controlPlane.enabled'), ...controlPlaneBinding },
276
+ httpListener: { enabled: config.get('danger.httpListener'), ...httpListenerBinding },
277
+ web: { enabled: config.get('web.enabled'), ...webBinding },
278
+ },
279
+ onboarding: {
280
+ completed: Boolean(marker?.payload),
281
+ scope: marker?.scope ?? 'none',
282
+ updatedAt: marker?.payload?.updatedAt ?? null,
283
+ },
284
+ findings,
285
+ };
286
+ }
287
+
288
+ export function renderCliStatus(options: CliStatusOptions): string {
289
+ const config = options.configManager;
290
+ const snapshot = buildCliStatusSnapshot(options);
291
+ const serviceEnabled = snapshot.service.enabled;
292
+ const serviceAutostart = snapshot.service.autostart;
293
+ const restartOnFailure = snapshot.service.restartOnFailure;
294
+ const controlPlaneEnabled = snapshot.surfaces.controlPlane.enabled;
295
+ const listenerEnabled = snapshot.surfaces.httpListener.enabled;
296
+ const webEnabled = snapshot.surfaces.web.enabled;
297
+ const controlPlaneBinding = snapshot.surfaces.controlPlane;
298
+ const httpListenerBinding = snapshot.surfaces.httpListener;
299
+ const webBinding = snapshot.surfaces.web;
300
+ const marker = options.onboardingMarkers?.effective;
301
+ const findings = snapshot.findings;
302
+
303
+ if (options.outputFormat === 'json') return JSON.stringify(snapshot, null, 2);
52
304
 
53
305
  const lines = [
54
- options.doctor ? 'GoodVibes doctor' : 'GoodVibes status',
306
+ snapshot.title,
55
307
  ` workingDir: ${options.workingDirectory}`,
56
308
  ` homeDir: ${options.homeDirectory}`,
57
309
  '',
@@ -61,8 +313,8 @@ export function renderCliStatus(options: CliStatusOptions): string {
61
313
  ` reasoning: ${String(config.get('provider.reasoningEffort'))}`,
62
314
  '',
63
315
  'Auth:',
64
- ` permissions: ${String(config.get('permissions.mode'))}`,
65
- ` secretPolicy: ${String(config.get('storage.secretPolicy'))}`,
316
+ ` permissions: ${permissionModeLabel(config.get('permissions.mode'))} (${String(config.get('permissions.mode'))})`,
317
+ ` secretPolicy: ${secretPolicyLabel(config.get('storage.secretPolicy'))} (${String(config.get('storage.secretPolicy'))})`,
66
318
  options.auth
67
319
  ? ` localUsers: ${options.auth.userStorePresent ? 'present' : 'missing'} (${options.auth.userStorePath})`
68
320
  : ' localUsers: unknown',
@@ -77,6 +329,14 @@ export function renderCliStatus(options: CliStatusOptions): string {
77
329
  ` enabled: ${yesNo(serviceEnabled)}`,
78
330
  ` autostart: ${yesNo(serviceAutostart)}`,
79
331
  ` restartOnFailure: ${yesNo(restartOnFailure)}`,
332
+ ...(options.service ? [
333
+ ` platform: ${options.service.managed.platform}`,
334
+ ` installed: ${yesNo(options.service.managed.installed)}`,
335
+ ` running: ${yesNo(options.service.managed.running)}`,
336
+ ` pid: ${options.service.managed.pid ?? 'n/a'}`,
337
+ ` definition: ${options.service.managed.path}`,
338
+ ` log: ${options.service.log.path ?? 'n/a'} (${options.service.log.exists ? 'present' : 'missing'})`,
339
+ ] : []),
80
340
  '',
81
341
  'Surfaces:',
82
342
  bindLine('controlPlane', controlPlaneEnabled, controlPlaneBinding),
@@ -91,8 +351,18 @@ export function renderCliStatus(options: CliStatusOptions): string {
91
351
 
92
352
  if (options.doctor) {
93
353
  lines.push('', 'Warnings:');
94
- if (warnings.length === 0) lines.push(' none');
95
- else lines.push(...warnings.map((warning) => ` - ${warning}`));
354
+ if (findings.length === 0) {
355
+ lines.push(' none');
356
+ } else {
357
+ for (const finding of findings) {
358
+ lines.push(
359
+ ` - [${finding.severity}:${finding.area}:${finding.id}] ${finding.summary}`,
360
+ ` cause: ${finding.cause}`,
361
+ ` impact: ${finding.impact}`,
362
+ ` action: ${finding.action}`,
363
+ );
364
+ }
365
+ }
96
366
  }
97
367
 
98
368
  return lines.join('\n');
@@ -0,0 +1,248 @@
1
+ import type { ConfigKey } from '../config/index.ts';
2
+ import { resolveRuntimeEndpointBinding } from './endpoints.ts';
3
+ import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
4
+ import type { CliCommandRuntime } from './management.ts';
5
+ import {
6
+ applyTargetEndpointFlagsOrDefault,
7
+ enableEndpointLanDefault,
8
+ enableServicePosture,
9
+ formatJsonOrText,
10
+ isPresentConfigValue,
11
+ probeTcp,
12
+ readAuthPaths,
13
+ yesNo,
14
+ } from './management.ts';
15
+
16
+ export const SURFACE_CONFIGS = [
17
+ ['slack', 'Slack', ['surfaces.slack.signingSecret', 'surfaces.slack.botToken']],
18
+ ['discord', 'Discord', ['surfaces.discord.publicKey', 'surfaces.discord.botToken', 'surfaces.discord.applicationId']],
19
+ ['telegram', 'Telegram', ['surfaces.telegram.botToken']],
20
+ ['webhook', 'Webhook', ['surfaces.webhook.secret']],
21
+ ['ntfy', 'ntfy', ['surfaces.ntfy.baseUrl', 'surfaces.ntfy.topic']],
22
+ ['googleChat', 'Google Chat', ['surfaces.googleChat.webhookUrl']],
23
+ ['signal', 'Signal', ['surfaces.signal.bridgeUrl', 'surfaces.signal.account']],
24
+ ['whatsapp', 'WhatsApp', ['surfaces.whatsapp.accessToken', 'surfaces.whatsapp.phoneNumberId']],
25
+ ['imessage', 'iMessage', ['surfaces.imessage.bridgeUrl', 'surfaces.imessage.account']],
26
+ ['msteams', 'Microsoft Teams', ['surfaces.msteams.appId', 'surfaces.msteams.appPassword']],
27
+ ['bluebubbles', 'BlueBubbles', ['surfaces.bluebubbles.serverUrl', 'surfaces.bluebubbles.password']],
28
+ ['mattermost', 'Mattermost', ['surfaces.mattermost.baseUrl', 'surfaces.mattermost.botToken']],
29
+ ['matrix', 'Matrix', ['surfaces.matrix.homeserverUrl', 'surfaces.matrix.accessToken', 'surfaces.matrix.userId']],
30
+ ] as const;
31
+
32
+ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise<{ readonly output: string; readonly exitCode: number }> {
33
+ const config = runtime.configManager;
34
+ const [sub = 'list', ...rest] = runtime.cli.commandArgs;
35
+ const target = rest[0];
36
+ if (sub === 'enable' || sub === 'disable') {
37
+ if (!target) return { output: `Usage: goodvibes surfaces ${sub} <web|listener|control-plane|surfaceId>`, exitCode: 2 };
38
+ const enabled = sub === 'enable';
39
+ if (target === 'web') {
40
+ runtime.configManager.setDynamic('web.enabled', enabled);
41
+ if (enabled) {
42
+ runtime.configManager.setDynamic('danger.daemon', true);
43
+ runtime.configManager.setDynamic('controlPlane.enabled', true);
44
+ const webError = applyTargetEndpointFlagsOrDefault(runtime, 'web');
45
+ if (webError) return { output: webError, exitCode: 2 };
46
+ const webBinding = resolveRuntimeEndpointBinding(runtime.configManager, 'web');
47
+ if (runtime.cli.flags.hostname !== undefined && webBinding.hostMode === 'local') {
48
+ runtime.configManager.setDynamic('controlPlane.hostMode', 'local');
49
+ runtime.configManager.setDynamic('controlPlane.host', '127.0.0.1');
50
+ runtime.configManager.setDynamic('controlPlane.allowRemote', false);
51
+ } else {
52
+ enableEndpointLanDefault(runtime.configManager, 'controlPlane');
53
+ }
54
+ }
55
+ }
56
+ else if (target === 'listener' || target === 'http-listener') {
57
+ runtime.configManager.setDynamic('danger.httpListener', enabled);
58
+ if (enabled) {
59
+ const listenerError = applyTargetEndpointFlagsOrDefault(runtime, 'httpListener');
60
+ if (listenerError) return { output: listenerError, exitCode: 2 };
61
+ }
62
+ }
63
+ else if (target === 'control-plane' || target === 'controlPlane') {
64
+ runtime.configManager.setDynamic('controlPlane.enabled', enabled);
65
+ runtime.configManager.setDynamic('danger.daemon', enabled);
66
+ if (enabled) {
67
+ const controlPlaneError = applyTargetEndpointFlagsOrDefault(runtime, 'controlPlane');
68
+ if (controlPlaneError) return { output: controlPlaneError, exitCode: 2 };
69
+ }
70
+ }
71
+ else if (SURFACE_CONFIGS.some(([id]) => id === target)) {
72
+ runtime.configManager.setDynamic(`surfaces.${target}.enabled` as ConfigKey, enabled);
73
+ if (enabled) {
74
+ runtime.configManager.setDynamic('danger.httpListener', true);
75
+ enableEndpointLanDefault(runtime.configManager, 'httpListener');
76
+ }
77
+ }
78
+ else return { output: `Unknown surface: ${target}`, exitCode: 1 };
79
+ if (enabled) {
80
+ enableServicePosture(runtime.configManager);
81
+ }
82
+ return { output: `Surface ${enabled ? 'enabled' : 'disabled'}: ${target}`, exitCode: 0 };
83
+ }
84
+ if (sub !== 'list' && sub !== 'status' && sub !== 'check' && sub !== 'show') {
85
+ return { output: 'Usage: goodvibes surfaces [list|check|show <surfaceId>|enable <surfaceId>|disable <surfaceId>]', exitCode: 2 };
86
+ }
87
+ const controlPlane = resolveRuntimeEndpointBinding(config, 'controlPlane');
88
+ const web = resolveRuntimeEndpointBinding(config, 'web');
89
+ const httpListener = resolveRuntimeEndpointBinding(config, 'httpListener');
90
+ const includeProbe = sub === 'check';
91
+ const [controlPlaneReachable, webReachable, listenerReachable] = includeProbe
92
+ ? await Promise.all([
93
+ probeTcp(controlPlane.host, controlPlane.port),
94
+ probeTcp(web.host, web.port),
95
+ probeTcp(httpListener.host, httpListener.port),
96
+ ])
97
+ : [undefined, undefined, undefined];
98
+ const externalSurfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
99
+ const enabled = config.get(`surfaces.${id}.enabled` as ConfigKey);
100
+ const missing = requiredKeys.filter((key) => !isPresentConfigValue(config.get(key as ConfigKey)));
101
+ return {
102
+ id,
103
+ label,
104
+ enabled,
105
+ ready: !enabled || missing.length === 0,
106
+ missing,
107
+ };
108
+ });
109
+ const filteredSurfaces = target ? externalSurfaces.filter((surface) => surface.id === target) : externalSurfaces;
110
+ if (target && filteredSurfaces.length === 0) return { output: `Unknown surface: ${target}`, exitCode: 1 };
111
+ const readinessIssues: string[] = [];
112
+ if (includeProbe && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
113
+ readinessIssues.push(`Control plane is enabled but not reachable on ${controlPlane.host}:${controlPlane.port}.`);
114
+ }
115
+ if (includeProbe && config.get('web.enabled') === true && !webReachable) {
116
+ readinessIssues.push(`Web surface is enabled but not reachable on ${web.host}:${web.port}.`);
117
+ }
118
+ if (includeProbe && config.get('danger.httpListener') === true && !listenerReachable) {
119
+ readinessIssues.push(`HTTP listener is enabled but not reachable on ${httpListener.host}:${httpListener.port}.`);
120
+ }
121
+ for (const surface of filteredSurfaces) {
122
+ if (surface.enabled !== true) continue;
123
+ if (config.get('danger.httpListener') !== true) {
124
+ readinessIssues.push(`${surface.label} is enabled but the HTTP listener is disabled.`);
125
+ }
126
+ if (surface.missing.length > 0) {
127
+ readinessIssues.push(`${surface.label} is enabled but missing ${surface.missing.join(', ')}.`);
128
+ }
129
+ }
130
+ const value = {
131
+ controlPlane: {
132
+ enabled: config.get('controlPlane.enabled'),
133
+ hostMode: controlPlane.hostMode,
134
+ configuredHost: controlPlane.configuredHost,
135
+ host: controlPlane.host,
136
+ port: controlPlane.port,
137
+ reachable: controlPlaneReachable,
138
+ },
139
+ web: {
140
+ enabled: config.get('web.enabled'),
141
+ hostMode: web.hostMode,
142
+ configuredHost: web.configuredHost,
143
+ host: web.host,
144
+ port: web.port,
145
+ reachable: webReachable,
146
+ },
147
+ httpListener: {
148
+ enabled: config.get('danger.httpListener'),
149
+ hostMode: httpListener.hostMode,
150
+ configuredHost: httpListener.configuredHost,
151
+ host: httpListener.host,
152
+ port: httpListener.port,
153
+ reachable: listenerReachable,
154
+ },
155
+ surfaces: filteredSurfaces,
156
+ readinessIssues,
157
+ };
158
+ const output = formatJsonOrText(runtime.cli)(value, [
159
+ 'GoodVibes surfaces',
160
+ ` control-plane: ${yesNo(value.controlPlane.enabled)} (${value.controlPlane.hostMode} ${value.controlPlane.host}:${value.controlPlane.port})${includeProbe ? ` reachable=${yesNo(value.controlPlane.reachable)}` : ''}`,
161
+ ` web: ${yesNo(value.web.enabled)} (${value.web.hostMode} ${value.web.host}:${value.web.port})${includeProbe ? ` reachable=${yesNo(value.web.reachable)}` : ''}`,
162
+ ` http-listener: ${yesNo(value.httpListener.enabled)} (${value.httpListener.hostMode} ${value.httpListener.host}:${value.httpListener.port})${includeProbe ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
163
+ '',
164
+ 'External surfaces:',
165
+ ...value.surfaces.map((surface) => ` ${surface.label.padEnd(16)} enabled=${yesNo(surface.enabled)} ready=${yesNo(surface.ready)}${surface.enabled && surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}`),
166
+ ...(includeProbe ? [
167
+ readinessIssues.length === 0 ? 'Readiness: ready' : 'Readiness: needs attention',
168
+ ...readinessIssues.map((issue) => ` - ${issue}`),
169
+ ] : []),
170
+ ].join('\n'));
171
+ return { output, exitCode: includeProbe && readinessIssues.length > 0 ? 1 : 0 };
172
+ }
173
+
174
+ export interface ListenerTestResult {
175
+ readonly enabled: unknown;
176
+ readonly hostMode: string;
177
+ readonly configuredHost: string;
178
+ readonly host: string;
179
+ readonly port: number;
180
+ readonly posture: ReturnType<typeof classifyBindPosture>;
181
+ readonly reachable: boolean;
182
+ readonly service: {
183
+ readonly enabled: unknown;
184
+ readonly autostart: unknown;
185
+ readonly restartOnFailure: unknown;
186
+ };
187
+ readonly auth: ReturnType<typeof readAuthPaths>;
188
+ readonly surfaces: readonly {
189
+ readonly id: string;
190
+ readonly label: string;
191
+ readonly enabled: unknown;
192
+ readonly ready: boolean;
193
+ readonly missing: readonly string[];
194
+ }[];
195
+ readonly issues: readonly string[];
196
+ }
197
+
198
+ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promise<ListenerTestResult> {
199
+ const enabled = runtime.configManager.get('danger.httpListener');
200
+ const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'httpListener');
201
+ const posture = classifyBindPosture(binding);
202
+ const reachable = enabled === true ? await probeTcp(binding.host, binding.port) : false;
203
+ const auth = readAuthPaths(runtime);
204
+ const service = {
205
+ enabled: runtime.configManager.get('service.enabled'),
206
+ autostart: runtime.configManager.get('service.autostart'),
207
+ restartOnFailure: runtime.configManager.get('service.restartOnFailure'),
208
+ };
209
+ const surfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
210
+ const surfaceEnabled = runtime.configManager.get(`surfaces.${id}.enabled` as ConfigKey);
211
+ const missing = requiredKeys.filter((key) => !isPresentConfigValue(runtime.configManager.get(key as ConfigKey)));
212
+ return {
213
+ id,
214
+ label,
215
+ enabled: surfaceEnabled,
216
+ ready: surfaceEnabled !== true || missing.length === 0,
217
+ missing,
218
+ };
219
+ }).filter((surface) => surface.enabled === true);
220
+ const issues: string[] = [];
221
+ if (enabled !== true) issues.push('HTTP listener is disabled.');
222
+ if (enabled === true && service.enabled !== true) issues.push('HTTP listener is enabled but service mode is off.');
223
+ if (enabled === true && service.autostart !== true) issues.push('HTTP listener is enabled but service autostart is off.');
224
+ if (enabled === true && service.restartOnFailure !== true) issues.push('HTTP listener is enabled but service restart-on-failure is off.');
225
+ if (isNetworkFacing(enabled, binding) && !auth.userStorePresent) issues.push('Network-facing listener has no local auth user store.');
226
+ if (isNetworkFacing(enabled, binding) && auth.bootstrapCredentialPresent) issues.push('Network-facing listener still has a bootstrap credential file.');
227
+ for (const surface of surfaces) {
228
+ if (surface.missing.length > 0) issues.push(`${surface.label} is enabled but missing ${surface.missing.join(', ')}.`);
229
+ }
230
+ return { enabled, ...binding, posture, reachable, service, auth, surfaces, issues };
231
+ }
232
+
233
+ export function formatListenerTestResult(runtime: CliCommandRuntime, value: ListenerTestResult): string {
234
+ return formatJsonOrText(runtime.cli)(value, [
235
+ 'GoodVibes listener test',
236
+ ` enabled: ${yesNo(value.enabled)}`,
237
+ ` endpoint: ${value.hostMode} ${value.host}:${value.port}`,
238
+ ` bind posture: ${value.posture.label}`,
239
+ ` reachable: ${yesNo(value.reachable)}`,
240
+ ` service: enabled=${yesNo(value.service.enabled)} autostart=${yesNo(value.service.autostart)} restartOnFailure=${yesNo(value.service.restartOnFailure)}`,
241
+ ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
242
+ ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
243
+ value.surfaces.length === 0 ? ' enabled webhook surfaces: none' : ' enabled webhook surfaces:',
244
+ ...value.surfaces.map((surface) => ` ${surface.label}: ready=${yesNo(surface.ready)}${surface.missing.length > 0 ? ` missing=${surface.missing.join(',')}` : ''}`),
245
+ value.issues.length === 0 ? ' readiness: ready' : ' readiness: needs attention',
246
+ ...value.issues.map((issue) => ` - ${issue}`),
247
+ ].join('\n'));
248
+ }
package/src/cli/types.ts CHANGED
@@ -3,6 +3,7 @@ export type GoodVibesCliCommand =
3
3
  | 'run'
4
4
  | 'serve'
5
5
  | 'web'
6
+ | 'service'
6
7
  | 'status'
7
8
  | 'doctor'
8
9
  | 'onboarding'
@@ -27,6 +28,11 @@ export type GoodVibesCliCommand =
27
28
 
28
29
  export type GoodVibesCliOutputFormat = 'text' | 'json' | 'stream-json';
29
30
 
31
+ export interface CliCommandOutput {
32
+ readonly output: string;
33
+ readonly exitCode: number;
34
+ }
35
+
30
36
  export interface GoodVibesCliFlags {
31
37
  readonly provider: string | undefined;
32
38
  readonly model: string | undefined;
package/src/cli-flags.ts CHANGED
@@ -8,6 +8,7 @@ export {
8
8
  applyRuntimeFeatureFlagOverrides,
9
9
  handleGoodVibesCliCommand,
10
10
  parseGoodVibesCli,
11
+ renderGoodVibesCommandHelp,
11
12
  renderGoodVibesHelp,
12
13
  renderGoodVibesVersion,
13
14
  } from './cli/index.ts';
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.25';
9
+ let _version = '0.19.26';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;