@pellux/goodvibes-tui 1.18.0 → 1.19.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.18.0",
3
+ "version": "1.19.1",
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",
@@ -104,8 +104,8 @@
104
104
  "@anthropic-ai/vertex-sdk": "^0.16.0",
105
105
  "@ast-grep/napi": "^0.42.0",
106
106
  "@aws/bedrock-token-generator": "^1.1.0",
107
- "@pellux/goodvibes-sdk": "1.9.0",
108
- "@pellux/goodvibes-terminal-shell": "1.9.0",
107
+ "@pellux/goodvibes-sdk": "1.10.1",
108
+ "@pellux/goodvibes-terminal-shell": "1.10.1",
109
109
  "bash-language-server": "^5.6.0",
110
110
  "fuse.js": "^7.1.0",
111
111
  "graphql": "^16.13.2",
@@ -131,10 +131,10 @@
131
131
  "typescript": "^5.9.3"
132
132
  },
133
133
  "optionalDependencies": {
134
- "@pellux/goodvibes-tui-linux-x64": "1.18.0",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.18.0",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.18.0",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.18.0"
134
+ "@pellux/goodvibes-tui-linux-x64": "1.19.1",
135
+ "@pellux/goodvibes-tui-linux-arm64": "1.19.1",
136
+ "@pellux/goodvibes-tui-darwin-x64": "1.19.1",
137
+ "@pellux/goodvibes-tui-darwin-arm64": "1.19.1"
138
138
  },
139
139
  "trustedDependencies": [],
140
140
  "overrides": {
@@ -36,6 +36,30 @@ export interface RuntimeEndpointBinding {
36
36
  readonly configuredHost: string;
37
37
  readonly host: string;
38
38
  readonly port: number;
39
+ /**
40
+ * False when the stored hostMode is not one of the SDK's recognized modes
41
+ * ('local' | 'network' | 'custom'). The SDK's resolveHostBinding is a switch
42
+ * with NO default case: an unrecognized mode yields an undefined binding and
43
+ * the daemon throws in its constructor before ever binding. Displays must
44
+ * therefore never present the host/port below as a definite binding when
45
+ * this is false — the fallback values exist only so display code has
46
+ * something structured to show alongside the warning.
47
+ */
48
+ readonly recognized: boolean;
49
+ }
50
+
51
+ /**
52
+ * THE one display seam for endpoint bindings: every surface that renders a
53
+ * binding as text goes through this, so no surface can present the resolver's
54
+ * loopback fallback as a definite bind for a hostMode the SDK cannot handle
55
+ * (its bind resolver has no default case — the daemon throws before binding).
56
+ * The recognized case renders the familiar `<mode> <host>:<port>`.
57
+ */
58
+ export function formatRuntimeEndpointBinding(binding: RuntimeEndpointBinding): string {
59
+ if (binding.recognized) {
60
+ return `${binding.hostMode} ${binding.host}:${binding.port}`;
61
+ }
62
+ return `'${binding.hostMode}' — not a recognized host mode (expected local|network|custom); the daemon cannot bind this endpoint until it is corrected`;
39
63
  }
40
64
 
41
65
  export function hostModeForHostname(hostname: string): RuntimeHostMode {
@@ -52,12 +76,34 @@ export function resolveRuntimeEndpointBinding(
52
76
  const keys = RUNTIME_ENDPOINT_CONFIG_KEYS[endpoint];
53
77
  const hostMode = String(config.get(keys.hostMode) ?? 'local');
54
78
  const configuredHost = String(config.get(keys.host) ?? '127.0.0.1');
55
- const port = Number(config.get(keys.port) || RUNTIME_ENDPOINT_DEFAULT_PORTS[endpoint]);
79
+ // Port coercion is anchored PER ENDPOINT to what the SDK actually does with
80
+ // the stored value, so a display never disagrees with the machinery:
81
+ // - controlPlane / httpListener bind via resolveHostBinding, which is fed
82
+ // `Number(raw ?? default)` and applies `customPort || DEFAULT` — a
83
+ // stored 0 or non-numeric value collapses to the endpoint default.
84
+ // - web has NO resolveHostBinding consumer in the SDK at all; its closest
85
+ // machinery (resolveWebPort, which drives tailscale-serve, plus the
86
+ // surface registry/announcements) uses bare `Number(raw ?? default)`
87
+ // with no || collapse — 0 stays 0 and a non-numeric value stays NaN.
88
+ // Collapsing here would make the display assert a port the tailscale/
89
+ // announcement machinery is not actually using. (The SDK's web-port
90
+ // handling is inconsistent-by-absence — a proper resolveHostBinding-style
91
+ // web resolver is an SDK-side fix; until then this mirrors resolveWebPort.)
92
+ const rawPort = Number(config.get(keys.port) ?? RUNTIME_ENDPOINT_DEFAULT_PORTS[endpoint]);
93
+ const port = endpoint === 'web' ? rawPort : (rawPort || RUNTIME_ENDPOINT_DEFAULT_PORTS[endpoint]);
56
94
  if (hostMode === 'network') {
57
- return { hostMode, configuredHost, host: '0.0.0.0', port };
95
+ return { hostMode, configuredHost, host: '0.0.0.0', port, recognized: true };
58
96
  }
59
97
  if (hostMode === 'custom') {
60
- return { hostMode, configuredHost, host: configuredHost || '127.0.0.1', port };
98
+ return { hostMode, configuredHost, host: configuredHost || '127.0.0.1', port, recognized: true };
99
+ }
100
+ if (hostMode === 'local') {
101
+ return { hostMode, configuredHost, host: '127.0.0.1', port, recognized: true };
61
102
  }
62
- return { hostMode, configuredHost, host: '127.0.0.1', port };
103
+ // Unrecognized hostMode ('LAN', 'Network', '', a trimmed variant, …): the
104
+ // SDK bind path has NO default case for this — resolveHostBinding returns
105
+ // undefined and the daemon throws before binding. There IS no real binding
106
+ // to display; recognized:false tells callers to warn instead of asserting
107
+ // the loopback fallback below as fact.
108
+ return { hostMode, configuredHost, host: '127.0.0.1', port, recognized: false };
63
109
  }
package/src/cli/help.ts CHANGED
@@ -6,8 +6,13 @@ import { VERSION } from '../version.ts';
6
6
  function readJsonVersion(path: string): string | null {
7
7
  try {
8
8
  if (!existsSync(path)) return null;
9
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown };
10
- return typeof parsed.version === 'string' ? parsed.version : null;
9
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { name?: unknown; version?: unknown };
10
+ // Only trust OUR package.json — a compiled single-file binary can resolve
11
+ // this path to a different package.json (a bundled dependency's) that
12
+ // reports a placeholder like "0.0.0". Fall through to the baked VERSION in
13
+ // that case rather than rendering a stray version in `--version`/banners.
14
+ if (parsed.name !== '@pellux/goodvibes-tui') return null;
15
+ return typeof parsed.version === 'string' && parsed.version.length > 0 ? parsed.version : null;
11
16
  } catch {
12
17
  return null;
13
18
  }
@@ -23,6 +28,28 @@ export function renderGoodVibesVersion(binary = 'goodvibes'): string {
23
28
  return `${binary} ${getPackageVersion()}`;
24
29
  }
25
30
 
31
+ /**
32
+ * Honest one-line startup identity for the daemon binary, emitted right as it
33
+ * begins serving — including on a bare (no-arg) systemd launch. It states the
34
+ * RESOLVED version (never a placeholder), the home/host/port it actually bound,
35
+ * and points at the real service-setup command. This replaces the field
36
+ * behavior where a bare launch showed a wrong "v0.0.0" banner and gave an
37
+ * operator nothing to act on. `version` is passed in (never read from the live
38
+ * build here) so callers/tests can pin a sentinel and never compare the live
39
+ * VERSION.
40
+ */
41
+ export function renderDaemonStartupBanner(
42
+ version: string,
43
+ binding: { readonly homeDir: string; readonly host: string; readonly port: number },
44
+ binary = 'goodvibes-daemon',
45
+ ): string {
46
+ return (
47
+ `${binary} ${version} starting — ` +
48
+ `home=${binding.homeDir} host=${binding.host} port=${binding.port} ` +
49
+ `(manage as a service: ${binary} install-service)`
50
+ );
51
+ }
52
+
26
53
  export function renderGoodVibesHelp(binary = 'goodvibes'): string {
27
54
  return [
28
55
  `Usage: ${binary} [OPTIONS] [PROMPT]`,
@@ -10,7 +10,7 @@ import { ensurePublicBaseUrl } from '../core/pairing-origin.ts';
10
10
  import { formatPairingOffers, formatPostureCapabilities, pairingPostureNotice } from '../core/pairing-offers.ts';
11
11
  import { defaultPairingTokenName } from '../core/pairing-handoff.ts';
12
12
  import { stableUrlHostForBindHost } from '../core/stable-host.ts';
13
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
13
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
14
14
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
15
15
  import type { CliCommandRuntime } from './types.ts';
16
16
  import type { RuntimeServices } from '../runtime/services.ts';
@@ -313,8 +313,10 @@ export interface ControlPlaneStatusResult {
313
313
  readonly configuredHost: string;
314
314
  readonly host: string;
315
315
  readonly port: number;
316
+ readonly recognized: boolean;
316
317
  readonly posture: ReturnType<typeof classifyBindPosture>;
317
- readonly reachable: boolean;
318
+ /** undefined = NOT PROBED (unrecognized host mode) — a tri-state, never coerced to false. */
319
+ readonly reachable: boolean | undefined;
318
320
  readonly auth: ReturnType<typeof readAuthPaths>;
319
321
  readonly service: {
320
322
  readonly enabled: unknown;
@@ -327,7 +329,13 @@ export interface ControlPlaneStatusResult {
327
329
  export async function buildControlPlaneStatusResult(runtime: CliCommandRuntime): Promise<ControlPlaneStatusResult> {
328
330
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
329
331
  const enabled = runtime.configManager.get('controlPlane.enabled');
330
- const reachable = enabled === true ? await probeTcp(binding.host, binding.port) : false;
332
+ // Never TCP-probe the fallback endpoint of an unrecognized hostMode — a
333
+ // probe asserts a binding that does not exist. Not-probed is its own state
334
+ // (undefined), NEVER coerced to a definite false: a daemon launched with
335
+ // flag overrides can be serving healthily regardless of the stored mode.
336
+ const reachable = enabled === true
337
+ ? (binding.recognized ? await probeTcp(binding.host, binding.port) : undefined)
338
+ : false;
331
339
  const auth = readAuthPaths(runtime);
332
340
  const service = {
333
341
  enabled: runtime.configManager.get('service.enabled'),
@@ -335,7 +343,8 @@ export async function buildControlPlaneStatusResult(runtime: CliCommandRuntime):
335
343
  restartOnFailure: runtime.configManager.get('service.restartOnFailure'),
336
344
  };
337
345
  const issues: string[] = [];
338
- if (enabled === true && !reachable) issues.push(`Control plane is enabled but not reachable on ${binding.host}:${binding.port}.`);
346
+ if (!binding.recognized) issues.push(`controlPlane.hostMode '${binding.hostMode}' is not a recognized mode (local|network|custom) — the daemon cannot bind until it is corrected.`);
347
+ if (enabled === true && binding.recognized && !reachable) issues.push(`Control plane is enabled but not reachable on ${binding.host}:${binding.port}.`);
339
348
  if (enabled === true && service.enabled !== true) issues.push('Control plane is enabled but service mode is off.');
340
349
  if (enabled === true && service.autostart !== true) issues.push('Control plane is enabled but service autostart is off.');
341
350
  if (enabled === true && service.restartOnFailure !== true) issues.push('Control plane is enabled but service restart-on-failure is off.');
@@ -356,9 +365,9 @@ export function formatControlPlaneStatus(runtime: CliCommandRuntime, value: Cont
356
365
  return formatJsonOrText(runtime.cli)(value, [
357
366
  'GoodVibes control-plane status',
358
367
  ` enabled: ${yesNo(value.enabled)}`,
359
- ` bind: ${value.hostMode} ${value.host}:${value.port}`,
360
- ` bind posture: ${value.posture.label}`,
361
- ` reachable: ${yesNo(value.reachable)}`,
368
+ ` bind: ${formatRuntimeEndpointBinding(value)}`,
369
+ ` bind posture: ${value.recognized ? value.posture.label : 'unknown (unrecognized host mode)'}`,
370
+ ` reachable: ${value.reachable === undefined ? 'not probed (unrecognized host mode)' : yesNo(value.reachable)}`,
362
371
  ` service: enabled=${yesNo(value.service.enabled)} autostart=${yesNo(value.service.autostart)} restartOnFailure=${yesNo(value.service.restartOnFailure)}`,
363
372
  ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
364
373
  ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
@@ -486,7 +495,7 @@ export function renderWeb(runtime: CliCommandRuntime): string {
486
495
  return formatJsonOrText(runtime.cli)(value, [
487
496
  'GoodVibes web',
488
497
  ` enabled: ${yesNo(value.enabled)}`,
489
- ` bind: ${value.hostMode} ${value.host}:${value.port}`,
498
+ ` bind: ${formatRuntimeEndpointBinding(value)}`,
490
499
  ` url: ${value.url}`,
491
500
  ...(runtime.cli.flags.open ? [` open: ${openBrowser(value.url)}`] : []),
492
501
  ].join('\n'));
@@ -7,7 +7,7 @@ import { PlatformServiceManager } from '@pellux/goodvibes-sdk/platform/daemon';
7
7
  import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
8
8
  import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
9
9
  import type { ConfigManager } from '../config/index.ts';
10
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
10
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
11
11
  import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
12
12
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
13
13
  import { redactText } from './redaction.ts';
@@ -395,7 +395,9 @@ export async function buildCliServicePosture(
395
395
  binding,
396
396
  bindPosture: classifyBindPosture(binding),
397
397
  networkFacing: isNetworkFacing(enabled, binding),
398
- ...(options.probe && enabled ? { reachable: await probeTcp(binding.host, binding.port) } : {}),
398
+ // Never TCP-probe the fallback endpoint of an unrecognized hostMode —
399
+ // a probe asserts a binding that does not exist.
400
+ ...(options.probe && enabled && binding.recognized ? { reachable: await probeTcp(binding.host, binding.port) } : {}),
399
401
  };
400
402
  }));
401
403
 
@@ -485,7 +487,7 @@ export function formatCliServicePosture(posture: CliServicePosture, json = false
485
487
  '',
486
488
  'Endpoints:',
487
489
  ...posture.endpoints.map((endpoint) =>
488
- ` ${endpoint.label}: enabled=${yesNo(endpoint.enabled)} ${endpoint.binding.hostMode} ${endpoint.binding.host}:${endpoint.binding.port} posture=${endpoint.bindPosture.label}${endpoint.reachable === undefined ? '' : ` reachable=${yesNo(endpoint.reachable)}`}`,
490
+ ` ${endpoint.label}: enabled=${yesNo(endpoint.enabled)} ${formatRuntimeEndpointBinding(endpoint.binding)} posture=${endpoint.binding.recognized ? endpoint.bindPosture.label : 'unknown'}${endpoint.reachable === undefined ? '' : ` reachable=${yesNo(endpoint.reachable)}`}`,
489
491
  ),
490
492
  '',
491
493
  posture.issues.length === 0 ? 'Readiness: ready' : 'Readiness: needs attention',
package/src/cli/status.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import { resolveDaemonEnabled } from '@pellux/goodvibes-sdk/platform/config';
3
3
  import type { OnboardingCheckMarkersState } from '../runtime/onboarding/index.ts';
4
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
5
- import type { RuntimeEndpointId } from './endpoints.ts';
4
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
5
+ import type { RuntimeEndpointBinding, RuntimeEndpointId } from './endpoints.ts';
6
6
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
7
7
  import type { GoodVibesCliOutputFormat } from './types.ts';
8
8
  import type { CliServicePosture } from './service-posture.ts';
@@ -165,8 +165,8 @@ function secretPolicyLabel(policy: unknown): string {
165
165
  return String(policy ?? 'unknown');
166
166
  }
167
167
 
168
- function bindLine(label: string, enabled: unknown, binding: { readonly hostMode: string; readonly host: string; readonly port: number }): string {
169
- return ` ${label}: ${yesNo(enabled)} (${binding.hostMode} ${binding.host}:${binding.port})`;
168
+ function bindLine(label: string, enabled: unknown, binding: RuntimeEndpointBinding): string {
169
+ return ` ${label}: ${yesNo(enabled)} (${formatRuntimeEndpointBinding(binding)})`;
170
170
  }
171
171
 
172
172
  /** Describe registration coverage for the Workspace status section. */
@@ -300,8 +300,8 @@ export function buildCliExposureReport(options: CliStatusOptions): readonly CliE
300
300
  id: surface.id,
301
301
  label: surface.label,
302
302
  enabled,
303
- bind: `${binding.hostMode} ${binding.host}:${binding.port}`,
304
- reach: classifyBindPosture(binding).label,
303
+ bind: formatRuntimeEndpointBinding(binding),
304
+ reach: binding.recognized ? classifyBindPosture(binding).label : 'Unknown (unrecognized host mode)',
305
305
  networkFacing,
306
306
  authMode: surfaceAuthMode(networkFacing, options.auth),
307
307
  originAllowlist: surfaceOriginAllowlist(surface.id, config),
@@ -333,6 +333,27 @@ export function buildCliDoctorFindings(options: CliStatusOptions): readonly CliD
333
333
 
334
334
  const findings: CliDoctorFinding[] = [];
335
335
 
336
+ // An unrecognized hostMode has NO binding the SDK can produce (its bind
337
+ // resolver has no default case — the daemon throws before binding), so it
338
+ // is surfaced as a doctor finding on every endpoint that carries one,
339
+ // instead of the fallback loopback binding being presented as fact.
340
+ for (const [endpointId, endpointLabel, endpointBinding] of [
341
+ ['controlPlane', 'control plane', controlPlaneBinding],
342
+ ['httpListener', 'HTTP listener', httpListenerBinding],
343
+ ['web', 'web surface', webBinding],
344
+ ] as const) {
345
+ if (endpointBinding.recognized) continue;
346
+ findings.push({
347
+ id: `unrecognized-host-mode-${endpointId}`,
348
+ area: 'service',
349
+ severity: 'warning',
350
+ summary: `${endpointLabel} hostMode '${endpointBinding.hostMode}' is not a recognized mode.`,
351
+ cause: `${endpointId}.hostMode is set to '${endpointBinding.hostMode}', but only local|network|custom are recognized.`,
352
+ impact: 'The daemon cannot resolve a binding for this endpoint and will fail to start it; displays cannot show a real bind address.',
353
+ action: `Set ${endpointId}.hostMode to local, network, or custom.`,
354
+ });
355
+ }
356
+
336
357
  if (serverBackedEnabled && !serviceEnabled) {
337
358
  findings.push({
338
359
  id: 'service-disabled-for-server-surfaces',
@@ -6,7 +6,7 @@ import {
6
6
  resolveGoodVibesNtfyTopics,
7
7
  } from '@pellux/goodvibes-sdk/platform/integrations';
8
8
  import { enableFeatureFlags, getMissingSurfaceFeatureFlags, getServerSurfaceFeatureFlags, surfaceFeatureGateSettingsKeys } from '../runtime/surface-feature-flags.ts';
9
- import { resolveRuntimeEndpointBinding } from './endpoints.ts';
9
+ import { formatRuntimeEndpointBinding, resolveRuntimeEndpointBinding } from './endpoints.ts';
10
10
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
11
11
  import type { CliCommandRuntime } from './types.ts';
12
12
  import {
@@ -103,9 +103,9 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
103
103
  const shouldProbeListener = includeProbe && (!target || targetExternalSurface);
104
104
  const [controlPlaneReachable, webReachable, listenerReachable] = includeProbe
105
105
  ? await Promise.all([
106
- shouldProbeControlPlane ? probeTcp(controlPlane.host, controlPlane.port) : Promise.resolve(undefined),
107
- shouldProbeWeb ? probeTcp(web.host, web.port) : Promise.resolve(undefined),
108
- shouldProbeListener ? probeTcp(httpListener.host, httpListener.port) : Promise.resolve(undefined),
106
+ shouldProbeControlPlane && controlPlane.recognized ? probeTcp(controlPlane.host, controlPlane.port) : Promise.resolve(undefined),
107
+ shouldProbeWeb && web.recognized ? probeTcp(web.host, web.port) : Promise.resolve(undefined),
108
+ shouldProbeListener && httpListener.recognized ? probeTcp(httpListener.host, httpListener.port) : Promise.resolve(undefined),
109
109
  ])
110
110
  : [undefined, undefined, undefined];
111
111
  const externalSurfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
@@ -129,13 +129,18 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
129
129
  remoteTopic: String(config.get('surfaces.ntfy.remoteTopic' as ConfigKey) || GOODVIBES_NTFY_REMOTE_TOPIC),
130
130
  });
131
131
  const readinessIssues: string[] = [];
132
- if (shouldProbeControlPlane && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
132
+ for (const [endpointLabel, endpointBinding] of [['controlPlane', controlPlane], ['web', web], ['httpListener', httpListener]] as const) {
133
+ if (!endpointBinding.recognized) {
134
+ readinessIssues.push(`${endpointLabel}.hostMode '${endpointBinding.hostMode}' is not a recognized mode (local|network|custom) — the daemon cannot bind this endpoint until it is corrected.`);
135
+ }
136
+ }
137
+ if (shouldProbeControlPlane && controlPlane.recognized && config.get('controlPlane.enabled') === true && !controlPlaneReachable) {
133
138
  readinessIssues.push(`Control plane is enabled but not reachable on ${controlPlane.host}:${controlPlane.port}.`);
134
139
  }
135
- if (shouldProbeWeb && config.get('web.enabled') === true && !webReachable) {
140
+ if (shouldProbeWeb && web.recognized && config.get('web.enabled') === true && !webReachable) {
136
141
  readinessIssues.push(`Web surface is enabled but not reachable on ${web.host}:${web.port}.`);
137
142
  }
138
- if (shouldProbeListener && config.get('danger.httpListener') === true && !listenerReachable) {
143
+ if (shouldProbeListener && httpListener.recognized && config.get('danger.httpListener') === true && !listenerReachable) {
139
144
  readinessIssues.push(`HTTP listener is enabled but not reachable on ${httpListener.host}:${httpListener.port}.`);
140
145
  }
141
146
  for (const surface of filteredSurfaces) {
@@ -157,6 +162,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
157
162
  configuredHost: controlPlane.configuredHost,
158
163
  host: controlPlane.host,
159
164
  port: controlPlane.port,
165
+ recognized: controlPlane.recognized,
160
166
  reachable: controlPlaneReachable,
161
167
  },
162
168
  web: {
@@ -165,6 +171,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
165
171
  configuredHost: web.configuredHost,
166
172
  host: web.host,
167
173
  port: web.port,
174
+ recognized: web.recognized,
168
175
  reachable: webReachable,
169
176
  },
170
177
  httpListener: {
@@ -173,6 +180,7 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
173
180
  configuredHost: httpListener.configuredHost,
174
181
  host: httpListener.host,
175
182
  port: httpListener.port,
183
+ recognized: httpListener.recognized,
176
184
  reachable: listenerReachable,
177
185
  },
178
186
  surfaces: filteredSurfaces,
@@ -180,9 +188,9 @@ export async function handleSurfacesCommand(runtime: CliCommandRuntime): Promise
180
188
  };
181
189
  const output = formatJsonOrText(runtime.cli)(value, [
182
190
  'GoodVibes surfaces',
183
- ` control-plane: ${yesNo(value.controlPlane.enabled)} (${value.controlPlane.hostMode} ${value.controlPlane.host}:${value.controlPlane.port})${includeProbe ? ` reachable=${yesNo(value.controlPlane.reachable)}` : ''}`,
184
- ` web: ${yesNo(value.web.enabled)} (${value.web.hostMode} ${value.web.host}:${value.web.port})${includeProbe ? ` reachable=${yesNo(value.web.reachable)}` : ''}`,
185
- ` http-listener: ${yesNo(value.httpListener.enabled)} (${value.httpListener.hostMode} ${value.httpListener.host}:${value.httpListener.port})${includeProbe ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
191
+ ` control-plane: ${yesNo(value.controlPlane.enabled)} (${formatRuntimeEndpointBinding(value.controlPlane)})${includeProbe && value.controlPlane.reachable !== undefined ? ` reachable=${yesNo(value.controlPlane.reachable)}` : ''}`,
192
+ ` web: ${yesNo(value.web.enabled)} (${formatRuntimeEndpointBinding(value.web)})${includeProbe && value.web.reachable !== undefined ? ` reachable=${yesNo(value.web.reachable)}` : ''}`,
193
+ ` http-listener: ${yesNo(value.httpListener.enabled)} (${formatRuntimeEndpointBinding(value.httpListener)})${includeProbe && value.httpListener.reachable !== undefined ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
186
194
  '',
187
195
  'External surfaces:',
188
196
  ...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(',')}` : ''}${surface.enabled && surface.missingFeatureFlags.length > 0 ? ` settingsOff=${surfaceFeatureGateSettingsKeys(surface.missingFeatureFlags).join(',')}` : ''}`),
@@ -208,8 +216,10 @@ export interface ListenerTestResult {
208
216
  readonly configuredHost: string;
209
217
  readonly host: string;
210
218
  readonly port: number;
219
+ readonly recognized: boolean;
211
220
  readonly posture: ReturnType<typeof classifyBindPosture>;
212
- readonly reachable: boolean;
221
+ /** undefined = NOT PROBED (unrecognized host mode) — a tri-state, never coerced to false. */
222
+ readonly reachable: boolean | undefined;
213
223
  readonly service: {
214
224
  readonly enabled: unknown;
215
225
  readonly autostart: unknown;
@@ -231,7 +241,10 @@ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promi
231
241
  const enabled = runtime.configManager.get('danger.httpListener');
232
242
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'httpListener');
233
243
  const posture = classifyBindPosture(binding);
234
- const reachable = enabled === true ? await probeTcp(binding.host, binding.port) : false;
244
+ // Not-probed (unrecognized host mode) stays undefined — never a definite false.
245
+ const reachable = enabled === true
246
+ ? (binding.recognized ? await probeTcp(binding.host, binding.port) : undefined)
247
+ : false;
235
248
  const auth = readAuthPaths(runtime);
236
249
  const service = {
237
250
  enabled: runtime.configManager.get('service.enabled'),
@@ -252,6 +265,7 @@ export async function buildListenerTestResult(runtime: CliCommandRuntime): Promi
252
265
  };
253
266
  }).filter((surface) => surface.enabled === true);
254
267
  const issues: string[] = [];
268
+ if (!binding.recognized) issues.push(`httpListener.hostMode '${binding.hostMode}' is not a recognized mode (local|network|custom) — the daemon cannot bind until it is corrected.`);
255
269
  if (enabled !== true) issues.push('HTTP listener is disabled.');
256
270
  if (enabled === true && service.enabled !== true) issues.push('HTTP listener is enabled but service mode is off.');
257
271
  if (enabled === true && service.autostart !== true) issues.push('HTTP listener is enabled but service autostart is off.');
@@ -269,9 +283,9 @@ export function formatListenerTestResult(runtime: CliCommandRuntime, value: List
269
283
  return formatJsonOrText(runtime.cli)(value, [
270
284
  'GoodVibes listener test',
271
285
  ` enabled: ${yesNo(value.enabled)}`,
272
- ` endpoint: ${value.hostMode} ${value.host}:${value.port}`,
273
- ` bind posture: ${value.posture.label}`,
274
- ` reachable: ${yesNo(value.reachable)}`,
286
+ ` endpoint: ${formatRuntimeEndpointBinding(value)}`,
287
+ ` bind posture: ${value.recognized ? value.posture.label : 'unknown (unrecognized host mode)'}`,
288
+ ` reachable: ${value.reachable === undefined ? 'not probed (unrecognized host mode)' : yesNo(value.reachable)}`,
275
289
  ` service: enabled=${yesNo(value.service.enabled)} autostart=${yesNo(value.service.autostart)} restartOnFailure=${yesNo(value.service.restartOnFailure)}`,
276
290
  ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
277
291
  ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
@@ -0,0 +1,99 @@
1
+ // ---------------------------------------------------------------------------
2
+ // memory-diagnostics-gateway.ts
3
+ //
4
+ // The daemon-backed verb the /health memory (doctor) surface reads:
5
+ // ops.memory.get (GET /api/ops/memory) — the read-only MemoryGovernor snapshot.
6
+ // No named facade exists on the in-process OperatorClient, so — exactly like
7
+ // the memory-consolidation and voice-provisioning gateways — it goes over the
8
+ // generic operator invoke path (operator-rpc.ts's resolveOperatorRpc ->
9
+ // sdk.operator.invoke), reaching the SAME daemon the command layer does.
10
+ //
11
+ // A daemon that predates the memory governor answers an honest 501 (the verb is
12
+ // cataloged but has no handler when no live governor is composed) or a 404 (an
13
+ // older daemon without the route) — both mean "this daemon does not serve
14
+ // memory diagnostics", NOT a fabricated all-zero snapshot (see
15
+ // classifyMemoryDiagnosticsError). The interface is injectable so the command
16
+ // and its tests round-trip against a mocked daemon.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
+ import { GoodVibesSdkError } from '@pellux/goodvibes-sdk';
21
+ import { resolveOperatorRpc, describeOperatorRpcError } from '../input/commands/operator-rpc.ts';
22
+ import { memoryStatusLines, type MemoryGovernorSnapshotResult } from './memory-status.ts';
23
+
24
+ /** The narrow async verb surface the /health memory (doctor) surface drives. */
25
+ export interface MemoryDiagnosticsGateway {
26
+ /** Read the MemoryGovernor snapshot (ops.memory.get). */
27
+ fetchSnapshot(): Promise<MemoryGovernorSnapshotResult>;
28
+ }
29
+
30
+ /**
31
+ * Why the gateway could not be built (daemon disabled / no control-plane URL),
32
+ * surfaced verbatim so the surface prints an honest "unavailable" line rather
33
+ * than guessing — mirrors the memory-consolidation / voice gateways.
34
+ */
35
+ export type MemoryDiagnosticsGatewayResolution =
36
+ | { readonly available: true; readonly gateway: MemoryDiagnosticsGateway }
37
+ | { readonly available: false; readonly reason: string };
38
+
39
+ export interface MemoryDiagnosticsGatewayDeps {
40
+ readonly configManager: ConfigManager;
41
+ readonly homeDirectory: string | (() => string);
42
+ }
43
+
44
+ /**
45
+ * Build the live memory-diagnostics gateway over the generic operator invoke
46
+ * path — the same daemon resolution the command layer uses. Returns an honest
47
+ * unavailable reason when no daemon is reachable.
48
+ */
49
+ export function createMemoryDiagnosticsGateway(deps: MemoryDiagnosticsGatewayDeps): MemoryDiagnosticsGatewayResolution {
50
+ const rpc = resolveOperatorRpc({ configManager: deps.configManager, homeDirectory: deps.homeDirectory });
51
+ if (!rpc.available) return { available: false, reason: rpc.reason };
52
+ const { sdk } = rpc;
53
+ return {
54
+ available: true,
55
+ gateway: { fetchSnapshot: () => sdk.operator.invoke('ops.memory.get', {}) },
56
+ };
57
+ }
58
+
59
+ /** How an ops.memory.get rejection should render. */
60
+ export type MemoryDiagnosticsFailure =
61
+ | { readonly kind: 'unavailable'; readonly reason: string }
62
+ | { readonly kind: 'error'; readonly message: string };
63
+
64
+ /** The honest "no memory diagnostics" reason for a daemon that predates the governor verb. */
65
+ export const MEMORY_DIAGNOSTICS_UNAVAILABLE = 'this daemon does not serve memory diagnostics.';
66
+
67
+ /**
68
+ * Classify an ops.memory.get rejection: a 501 (a daemon composed without a live
69
+ * governor — the verb is cataloged but unhandled) or a 404 (an older daemon
70
+ * without the route) are both "verb unavailable"; anything else (network,
71
+ * 401/403, 500) is a generic error.
72
+ */
73
+ export function classifyMemoryDiagnosticsError(error: unknown): MemoryDiagnosticsFailure {
74
+ if (error instanceof GoodVibesSdkError && (error.status === 501 || error.status === 404)) {
75
+ return { kind: 'unavailable', reason: MEMORY_DIAGNOSTICS_UNAVAILABLE };
76
+ }
77
+ return { kind: 'error', message: describeOperatorRpcError(error) };
78
+ }
79
+
80
+ /**
81
+ * Render the /health memory (doctor) block from an already-resolved gateway
82
+ * resolution — the testable core the command wraps. Kept separate from the live
83
+ * gateway construction so a wire test injects a fake resolution (available /
84
+ * unavailable / failing) with no HTTP.
85
+ */
86
+ export async function renderMemoryDiagnostics(resolution: MemoryDiagnosticsGatewayResolution): Promise<string> {
87
+ if (!resolution.available) {
88
+ return `Health Review: Memory\n memory diagnostics unavailable: ${resolution.reason}`;
89
+ }
90
+ try {
91
+ const snapshot = await resolution.gateway.fetchSnapshot();
92
+ return ['Health Review: Memory', ...memoryStatusLines(snapshot)].join('\n');
93
+ } catch (error) {
94
+ const failure = classifyMemoryDiagnosticsError(error);
95
+ return failure.kind === 'unavailable'
96
+ ? `Health Review: Memory\n ${failure.reason}`
97
+ : `Health Review: Memory\n could not read memory diagnostics: ${failure.message}`;
98
+ }
99
+ }