@pellux/goodvibes-tui 0.19.25 → 0.19.27

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.
@@ -1,9 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join } from 'node:path';
3
- import type { ConfigKey, GoodVibesConfig } from '../config/index.ts';
4
- import { CONFIG_SCHEMA } from '../config/index.ts';
5
3
  import { SecretsManager } from '../config/secrets.ts';
6
- import { createShellPathService } from '@pellux/goodvibes-sdk/platform/runtime/shell-paths';
7
4
  import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, resolveSecretRef } from '@pellux/goodvibes-sdk/platform/config/secret-refs';
8
5
  import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
9
6
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config/openai-codex-auth';
@@ -11,8 +8,9 @@ import { inspectProviderAuth } from '@pellux/goodvibes-sdk/platform/runtime/auth
11
8
  import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing/index';
12
9
  import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing/qr-generator';
13
10
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
11
+ import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
14
12
  import type { CliCommandRuntime } from './management.ts';
15
- import { applyTargetEndpointFlagsOrDefault, enableEndpointLanDefault, enableServicePosture, extractAuthorizationCode, formatJsonOrText, getNestedValue, hasCommandFlag, isPresentConfigValue, openBrowser, probeTcp, readAuthPaths, runNonInteractiveAgent, SURFACE_CONFIGS, urlHostForBindHost, withRuntimeServices, yesNo } from './management.ts';
13
+ import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, runNonInteractiveAgent, urlHostForBindHost, withRuntimeServices, yesNo } from './management.ts';
16
14
 
17
15
  export async function renderSubscriptions(runtime: CliCommandRuntime): Promise<string> {
18
16
  return await withRuntimeServices(runtime, async (services) => {
@@ -305,157 +303,71 @@ export async function handleTasks(runtime: CliCommandRuntime): Promise<string> {
305
303
  });
306
304
  }
307
305
 
308
- export async function handleSurfaces(runtime: CliCommandRuntime): Promise<string> {
309
- const config = runtime.configManager;
310
- const [sub = 'list', ...rest] = runtime.cli.commandArgs;
311
- const target = rest[0];
312
- if (sub === 'enable' || sub === 'disable') {
313
- if (!target) return `Usage: goodvibes surfaces ${sub} <web|listener|control-plane|surfaceId>`;
314
- const enabled = sub === 'enable';
315
- if (target === 'web') {
316
- runtime.configManager.setDynamic('web.enabled', enabled);
317
- if (enabled) {
318
- runtime.configManager.setDynamic('danger.daemon', true);
319
- runtime.configManager.setDynamic('controlPlane.enabled', true);
320
- const webError = applyTargetEndpointFlagsOrDefault(runtime, 'web');
321
- if (webError) return webError;
322
- const webBinding = resolveRuntimeEndpointBinding(runtime.configManager, 'web');
323
- if (runtime.cli.flags.hostname !== undefined && webBinding.hostMode === 'local') {
324
- runtime.configManager.setDynamic('controlPlane.hostMode', 'local');
325
- runtime.configManager.setDynamic('controlPlane.host', '127.0.0.1');
326
- runtime.configManager.setDynamic('controlPlane.allowRemote', false);
327
- } else {
328
- enableEndpointLanDefault(runtime.configManager, 'controlPlane');
329
- }
330
- }
331
- }
332
- else if (target === 'listener' || target === 'http-listener') {
333
- runtime.configManager.setDynamic('danger.httpListener', enabled);
334
- if (enabled) {
335
- const listenerError = applyTargetEndpointFlagsOrDefault(runtime, 'httpListener');
336
- if (listenerError) return listenerError;
337
- }
338
- }
339
- else if (target === 'control-plane' || target === 'controlPlane') {
340
- runtime.configManager.setDynamic('controlPlane.enabled', enabled);
341
- runtime.configManager.setDynamic('danger.daemon', enabled);
342
- if (enabled) {
343
- const controlPlaneError = applyTargetEndpointFlagsOrDefault(runtime, 'controlPlane');
344
- if (controlPlaneError) return controlPlaneError;
345
- }
346
- }
347
- else if (SURFACE_CONFIGS.some(([id]) => id === target)) {
348
- runtime.configManager.setDynamic(`surfaces.${target}.enabled` as ConfigKey, enabled);
349
- if (enabled) {
350
- runtime.configManager.setDynamic('danger.httpListener', true);
351
- enableEndpointLanDefault(runtime.configManager, 'httpListener');
352
- }
353
- }
354
- else return `Unknown surface: ${target}`;
355
- if (enabled) {
356
- enableServicePosture(runtime.configManager);
357
- }
358
- return `Surface ${enabled ? 'enabled' : 'disabled'}: ${target}`;
359
- }
360
- if (sub !== 'list' && sub !== 'status' && sub !== 'check' && sub !== 'show') {
361
- return 'Usage: goodvibes surfaces [list|check|show <surfaceId>|enable <surfaceId>|disable <surfaceId>]';
362
- }
363
- const controlPlane = resolveRuntimeEndpointBinding(config, 'controlPlane');
364
- const web = resolveRuntimeEndpointBinding(config, 'web');
365
- const httpListener = resolveRuntimeEndpointBinding(config, 'httpListener');
366
- const includeProbe = sub === 'check';
367
- const [controlPlaneReachable, webReachable, listenerReachable] = includeProbe
368
- ? await Promise.all([
369
- probeTcp(controlPlane.host, controlPlane.port),
370
- probeTcp(web.host, web.port),
371
- probeTcp(httpListener.host, httpListener.port),
372
- ])
373
- : [undefined, undefined, undefined];
374
- const externalSurfaces = SURFACE_CONFIGS.map(([id, label, requiredKeys]) => {
375
- const enabled = config.get(`surfaces.${id}.enabled` as ConfigKey);
376
- const missing = requiredKeys.filter((key) => !isPresentConfigValue(config.get(key as ConfigKey)));
377
- return {
378
- id,
379
- label,
380
- enabled,
381
- ready: !enabled || missing.length === 0,
382
- missing,
383
- };
384
- });
385
- const filteredSurfaces = target ? externalSurfaces.filter((surface) => surface.id === target) : externalSurfaces;
386
- if (target && filteredSurfaces.length === 0) return `Unknown surface: ${target}`;
387
- const value = {
388
- controlPlane: {
389
- enabled: config.get('controlPlane.enabled'),
390
- hostMode: controlPlane.hostMode,
391
- configuredHost: controlPlane.configuredHost,
392
- host: controlPlane.host,
393
- port: controlPlane.port,
394
- reachable: controlPlaneReachable,
395
- },
396
- web: {
397
- enabled: config.get('web.enabled'),
398
- hostMode: web.hostMode,
399
- configuredHost: web.configuredHost,
400
- host: web.host,
401
- port: web.port,
402
- reachable: webReachable,
403
- },
404
- httpListener: {
405
- enabled: config.get('danger.httpListener'),
406
- hostMode: httpListener.hostMode,
407
- configuredHost: httpListener.configuredHost,
408
- host: httpListener.host,
409
- port: httpListener.port,
410
- reachable: listenerReachable,
411
- },
412
- surfaces: filteredSurfaces,
306
+ export interface ControlPlaneStatusResult {
307
+ readonly enabled: unknown;
308
+ readonly hostMode: string;
309
+ readonly configuredHost: string;
310
+ readonly host: string;
311
+ readonly port: number;
312
+ readonly posture: ReturnType<typeof classifyBindPosture>;
313
+ readonly reachable: boolean;
314
+ readonly auth: ReturnType<typeof readAuthPaths>;
315
+ readonly service: {
316
+ readonly enabled: unknown;
317
+ readonly autostart: unknown;
318
+ readonly restartOnFailure: unknown;
413
319
  };
414
- return formatJsonOrText(runtime.cli)(value, [
415
- 'GoodVibes surfaces',
416
- ` control-plane: ${yesNo(value.controlPlane.enabled)} (${value.controlPlane.hostMode} ${value.controlPlane.host}:${value.controlPlane.port})${includeProbe ? ` reachable=${yesNo(value.controlPlane.reachable)}` : ''}`,
417
- ` web: ${yesNo(value.web.enabled)} (${value.web.hostMode} ${value.web.host}:${value.web.port})${includeProbe ? ` reachable=${yesNo(value.web.reachable)}` : ''}`,
418
- ` http-listener: ${yesNo(value.httpListener.enabled)} (${value.httpListener.hostMode} ${value.httpListener.host}:${value.httpListener.port})${includeProbe ? ` reachable=${yesNo(value.httpListener.reachable)}` : ''}`,
419
- '',
420
- 'External surfaces:',
421
- ...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(',')}` : ''}`),
422
- ].join('\n'));
320
+ readonly issues: readonly string[];
423
321
  }
424
322
 
425
- export async function renderListenerTest(runtime: CliCommandRuntime): Promise<string> {
426
- const enabled = runtime.configManager.get('danger.httpListener');
427
- const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'httpListener');
428
- const reachable = await probeTcp(binding.host, binding.port);
429
- const value = { enabled, ...binding, reachable };
430
- return formatJsonOrText(runtime.cli)(value, [
431
- 'GoodVibes listener test',
432
- ` enabled: ${yesNo(enabled)}`,
433
- ` endpoint: ${binding.hostMode} ${binding.host}:${binding.port}`,
434
- ` reachable: ${yesNo(reachable)}`,
435
- ].join('\n'));
436
- }
437
-
438
- export async function renderControlPlaneStatus(runtime: CliCommandRuntime): Promise<string> {
323
+ export async function buildControlPlaneStatusResult(runtime: CliCommandRuntime): Promise<ControlPlaneStatusResult> {
439
324
  const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
440
- const reachable = await probeTcp(binding.host, binding.port);
325
+ const enabled = runtime.configManager.get('controlPlane.enabled');
326
+ const reachable = enabled === true ? await probeTcp(binding.host, binding.port) : false;
441
327
  const auth = readAuthPaths(runtime);
442
- const value = {
443
- enabled: runtime.configManager.get('controlPlane.enabled'),
328
+ const service = {
329
+ enabled: runtime.configManager.get('service.enabled'),
330
+ autostart: runtime.configManager.get('service.autostart'),
331
+ restartOnFailure: runtime.configManager.get('service.restartOnFailure'),
332
+ };
333
+ const issues: string[] = [];
334
+ if (enabled === true && !reachable) issues.push(`Control plane is enabled but not reachable on ${binding.host}:${binding.port}.`);
335
+ if (enabled === true && service.enabled !== true) issues.push('Control plane is enabled but service mode is off.');
336
+ if (enabled === true && service.autostart !== true) issues.push('Control plane is enabled but service autostart is off.');
337
+ if (enabled === true && service.restartOnFailure !== true) issues.push('Control plane is enabled but service restart-on-failure is off.');
338
+ if (isNetworkFacing(enabled, binding) && !auth.userStorePresent) issues.push('Network-facing control plane has no local auth user store.');
339
+ if (isNetworkFacing(enabled, binding) && auth.bootstrapCredentialPresent) issues.push('Network-facing control plane still has a bootstrap credential file.');
340
+ return {
341
+ enabled,
444
342
  ...binding,
343
+ posture: classifyBindPosture(binding),
445
344
  reachable,
446
345
  auth,
346
+ service,
347
+ issues,
447
348
  };
349
+ }
350
+
351
+ export function formatControlPlaneStatus(runtime: CliCommandRuntime, value: ControlPlaneStatusResult): string {
448
352
  return formatJsonOrText(runtime.cli)(value, [
449
353
  'GoodVibes control-plane status',
450
354
  ` enabled: ${yesNo(value.enabled)}`,
451
- ` bind: ${binding.hostMode} ${binding.host}:${binding.port}`,
452
- ` reachable: ${yesNo(reachable)}`,
453
- ` local auth users: ${auth.userStorePresent ? 'present' : 'missing'}`,
454
- ` bootstrap credential: ${auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
455
- ` operator tokens: ${auth.operatorTokenPresent ? 'present' : 'missing'}`,
355
+ ` bind: ${value.hostMode} ${value.host}:${value.port}`,
356
+ ` bind posture: ${value.posture.label}`,
357
+ ` reachable: ${yesNo(value.reachable)}`,
358
+ ` service: enabled=${yesNo(value.service.enabled)} autostart=${yesNo(value.service.autostart)} restartOnFailure=${yesNo(value.service.restartOnFailure)}`,
359
+ ` local auth users: ${value.auth.userStorePresent ? 'present' : 'missing'}`,
360
+ ` bootstrap credential: ${value.auth.bootstrapCredentialPresent ? 'present' : 'missing'}`,
361
+ ` operator tokens: ${value.auth.operatorTokenPresent ? 'present' : 'missing'}`,
362
+ value.issues.length === 0 ? ' readiness: ready' : ' readiness: needs attention',
363
+ ...value.issues.map((issue) => ` - ${issue}`),
456
364
  ].join('\n'));
457
365
  }
458
366
 
367
+ export async function renderControlPlaneStatus(runtime: CliCommandRuntime): Promise<string> {
368
+ return formatControlPlaneStatus(runtime, await buildControlPlaneStatusResult(runtime));
369
+ }
370
+
459
371
  export async function renderPairing(runtime: CliCommandRuntime): Promise<string> {
460
372
  const daemonHomeDir = join(runtime.homeDirectory, '.goodvibes', 'daemon');
461
373
  const tokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
@@ -471,68 +383,6 @@ export async function renderPairing(runtime: CliCommandRuntime): Promise<string>
471
383
  return [formatConnectionBlock(info, payload), '', qr].join('\n');
472
384
  }
473
385
 
474
- export async function handleBundle(runtime: CliCommandRuntime): Promise<string> {
475
- const [sub = 'inspect', ...rest] = runtime.cli.commandArgs;
476
- const shellPaths = createShellPathService({
477
- workingDirectory: runtime.workingDirectory,
478
- homeDirectory: runtime.homeDirectory,
479
- });
480
- if (sub === 'inspect') {
481
- const path = rest[0];
482
- if (!path) return 'Usage: goodvibes bundle inspect <path>';
483
- const sourcePath = shellPaths.resolveWorkspacePath(path);
484
- const parsed = JSON.parse(readFileSync(sourcePath, 'utf-8')) as Record<string, unknown>;
485
- return [
486
- 'GoodVibes bundle',
487
- ` type: ${String(parsed['type'] ?? 'unknown')}`,
488
- ` version: ${String(parsed['version'] ?? 'unknown')}`,
489
- ` path: ${sourcePath}`,
490
- ` capturedAt: ${parsed['capturedAt'] ? new Date(Number(parsed['capturedAt'])).toISOString() : 'n/a'}`,
491
- ` configKeys: ${parsed['config'] && typeof parsed['config'] === 'object' ? CONFIG_SCHEMA.filter((setting) => getNestedValue(parsed['config'], setting.key) !== undefined).length : 0}`,
492
- ].join('\n');
493
- }
494
- if (sub === 'export') {
495
- const outputPath = rest[0] ?? 'goodvibes-bundle.json';
496
- const secrets = new SecretsManager({
497
- projectRoot: runtime.workingDirectory,
498
- globalHome: runtime.homeDirectory,
499
- configManager: runtime.configManager,
500
- });
501
- const bundle = {
502
- version: 1,
503
- type: 'goodvibes.setup',
504
- capturedAt: Date.now(),
505
- workingDirectory: runtime.workingDirectory,
506
- config: runtime.configManager.getRaw(),
507
- secrets: await secrets.inspect(),
508
- onboarding: {
509
- projectMarker: existsSync(shellPaths.resolveProjectPath('tui', 'onboarding.json')),
510
- userMarker: existsSync(shellPaths.resolveUserPath('tui', 'onboarding.json')),
511
- },
512
- };
513
- const targetPath = shellPaths.resolveWorkspacePath(outputPath);
514
- mkdirSync(dirname(targetPath), { recursive: true });
515
- writeFileSync(targetPath, JSON.stringify(bundle, null, 2) + '\n', 'utf-8');
516
- return `Bundle exported: ${targetPath}`;
517
- }
518
- if (sub === 'import') {
519
- const path = rest[0];
520
- if (!path) return 'Usage: goodvibes bundle import <path>';
521
- const sourcePath = shellPaths.resolveWorkspacePath(path);
522
- const parsed = JSON.parse(readFileSync(sourcePath, 'utf-8')) as { config?: GoodVibesConfig };
523
- if (!parsed.config || typeof parsed.config !== 'object') return 'Bundle has no config object to import.';
524
- let count = 0;
525
- for (const setting of CONFIG_SCHEMA) {
526
- const value = getNestedValue(parsed.config, setting.key);
527
- if (value === undefined) continue;
528
- runtime.configManager.setDynamic(setting.key, value);
529
- count++;
530
- }
531
- return `Bundle imported: ${count} config value${count === 1 ? '' : 's'} applied.`;
532
- }
533
- return 'Usage: goodvibes bundle export [path]|inspect <path>|import <path>';
534
- }
535
-
536
386
  export async function renderRemote(runtime: CliCommandRuntime, label: 'remote' | 'bridge'): Promise<string> {
537
387
  return await withRuntimeServices(runtime, (services) => {
538
388
  const pools = services.remoteRunnerRegistry.listPools?.() ?? [];
@@ -21,10 +21,14 @@ import { inspectProviderAuth } from '@pellux/goodvibes-sdk/platform/runtime/auth
21
21
  import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing/index';
22
22
  import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing/qr-generator';
23
23
  import type { GoodVibesCliParseResult } from './types.ts';
24
+ import { classifyProviderSetup } from './provider-classification.ts';
24
25
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
25
26
  import { applyRuntimeEndpointFlagOverrides } from './config-overrides.ts';
26
27
  import type { RuntimeEndpointId } from './endpoints.ts';
27
- import { handleBundle, handleSecrets, handleSessions, handleSurfaces, handleTasks, renderControlPlaneStatus, renderListenerTest, renderPairing, renderRemote, renderSubscriptions, renderWeb } from './management-commands.ts';
28
+ import { handleServiceCommand } from './service-command.ts';
29
+ import { handleBundleCommand } from './bundle-command.ts';
30
+ import { buildListenerTestResult, formatListenerTestResult, handleSurfacesCommand } from './surface-command.ts';
31
+ import { buildControlPlaneStatusResult, formatControlPlaneStatus, handleSecrets, handleSessions, handleTasks, renderPairing, renderRemote, renderSubscriptions, renderWeb } from './management-commands.ts';
28
32
 
29
33
  export interface CliCommandRuntime {
30
34
  readonly cli: GoodVibesCliParseResult;
@@ -40,23 +44,6 @@ interface CliCommandResult {
40
44
 
41
45
  type Formatter = (value: unknown, text: string) => string;
42
46
 
43
- export const SURFACE_CONFIGS = [
44
- ['slack', 'Slack', ['surfaces.slack.signingSecret', 'surfaces.slack.botToken']],
45
- ['discord', 'Discord', ['surfaces.discord.publicKey', 'surfaces.discord.botToken', 'surfaces.discord.applicationId']],
46
- ['telegram', 'Telegram', ['surfaces.telegram.botToken']],
47
- ['webhook', 'Webhook', ['surfaces.webhook.secret']],
48
- ['ntfy', 'ntfy', ['surfaces.ntfy.baseUrl', 'surfaces.ntfy.topic']],
49
- ['googleChat', 'Google Chat', ['surfaces.googleChat.webhookUrl']],
50
- ['signal', 'Signal', ['surfaces.signal.bridgeUrl', 'surfaces.signal.account']],
51
- ['whatsapp', 'WhatsApp', ['surfaces.whatsapp.accessToken', 'surfaces.whatsapp.phoneNumberId']],
52
- ['imessage', 'iMessage', ['surfaces.imessage.bridgeUrl', 'surfaces.imessage.account']],
53
- ['msteams', 'Microsoft Teams', ['surfaces.msteams.appId', 'surfaces.msteams.appPassword']],
54
- ['bluebubbles', 'BlueBubbles', ['surfaces.bluebubbles.serverUrl', 'surfaces.bluebubbles.password']],
55
- ['mattermost', 'Mattermost', ['surfaces.mattermost.baseUrl', 'surfaces.mattermost.botToken']],
56
- ['matrix', 'Matrix', ['surfaces.matrix.homeserverUrl', 'surfaces.matrix.accessToken', 'surfaces.matrix.userId']],
57
- ] as const;
58
-
59
-
60
47
  export function yesNo(value: unknown): string {
61
48
  return value === true ? 'yes' : 'no';
62
49
  }
@@ -424,9 +411,16 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
424
411
  if (!provider) return 'Usage: goodvibes providers inspect <provider>';
425
412
  const snapshot = snapshots.find((candidate) => candidate.providerId === provider);
426
413
  if (!snapshot) return `No provider found: ${provider}`;
427
- return formatJsonOrText(runtime.cli)(snapshot, [
414
+ const setup = classifyProviderSetup({
415
+ providerId: snapshot.providerId,
416
+ authMode: snapshot.runtime.auth?.mode,
417
+ configured: snapshot.runtime.auth?.configured ?? true,
418
+ modelCount: snapshot.modelCount,
419
+ });
420
+ return formatJsonOrText(runtime.cli)({ ...snapshot, setup }, [
428
421
  `Provider ${snapshot.providerId}`,
429
422
  ` active: ${yesNo(snapshot.active)}`,
423
+ ` setup: ${setup.setupLabel}`,
430
424
  ` configured: ${yesNo(snapshot.runtime.auth?.configured ?? true)}`,
431
425
  ` via: ${snapshot.runtime.auth?.mode ?? 'unknown'}`,
432
426
  ` models: ${snapshot.modelCount}`,
@@ -435,6 +429,12 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
435
429
  }
436
430
  if (sub !== 'list') return 'Usage: goodvibes providers [list|current|inspect <provider>|use <provider> [modelRegistryKey]]';
437
431
  const value = snapshots.map((snapshot) => ({
432
+ ...classifyProviderSetup({
433
+ providerId: snapshot.providerId,
434
+ authMode: snapshot.runtime.auth?.mode,
435
+ configured: snapshot.runtime.auth?.configured ?? true,
436
+ modelCount: snapshot.modelCount,
437
+ }),
438
438
  provider: snapshot.providerId,
439
439
  active: snapshot.active,
440
440
  configured: snapshot.runtime.auth?.configured ?? true,
@@ -446,7 +446,7 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
446
446
  return formatJsonOrText(runtime.cli)(value, [
447
447
  'GoodVibes providers',
448
448
  ...value.map((provider) =>
449
- ` ${provider.current ? '*' : ' '} ${provider.provider.padEnd(18)} configured=${yesNo(provider.configured)} via=${provider.configuredVia ?? 'n/a'} models=${provider.models} ${provider.detail ?? ''}`.trimEnd(),
449
+ ` ${provider.current ? '*' : ' '} ${provider.provider.padEnd(18)} setup=${provider.setupClass} configured=${yesNo(provider.configured)} via=${provider.configuredVia ?? 'n/a'} models=${provider.models} ${provider.detail ?? ''}`.trimEnd(),
450
450
  ),
451
451
  ].join('\n'));
452
452
  });
@@ -456,19 +456,35 @@ async function renderModels(runtime: CliCommandRuntime): Promise<string> {
456
456
  return await withRuntimeServices(runtime, async (services) => {
457
457
  const [subOrFilter, ...rest] = runtime.cli.commandArgs;
458
458
  const current = services.providerRegistry.getCurrentModel().registryKey;
459
+ const providerSnapshots = await listProviderRuntimeSnapshots(services.providerRegistry);
460
+ const classifyModelProvider = (providerId: string) => {
461
+ const snapshot = providerSnapshots.find((candidate) => candidate.providerId === providerId);
462
+ return classifyProviderSetup({
463
+ providerId,
464
+ authMode: snapshot?.runtime.auth?.mode,
465
+ configured: snapshot?.runtime.auth?.configured,
466
+ modelCount: snapshot?.modelCount,
467
+ });
468
+ };
459
469
  if (subOrFilter === 'current') {
460
470
  const model = services.providerRegistry.getCurrentModel();
471
+ const setup = classifyModelProvider(model.provider);
472
+ const providerSnapshot = providerSnapshots.find((candidate) => candidate.providerId === model.provider);
461
473
  const value = {
462
474
  registryKey: model.registryKey,
463
475
  provider: model.provider,
464
476
  id: model.id,
465
477
  displayName: model.displayName,
466
478
  contextWindow: services.providerRegistry.getContextWindowForModel(model),
479
+ providerConfigured: providerSnapshot?.runtime.auth?.configured ?? true,
480
+ setup,
467
481
  };
468
482
  return formatJsonOrText(runtime.cli)(value, [
469
483
  'GoodVibes current model',
470
484
  ` model: ${model.registryKey}`,
471
485
  ` provider: ${model.provider}`,
486
+ ` setup: ${setup.setupLabel}`,
487
+ ` provider configured: ${yesNo(value.providerConfigured)}`,
472
488
  ` context: ${value.contextWindow.toLocaleString()}`,
473
489
  ].join('\n'));
474
490
  }
@@ -519,6 +535,7 @@ async function renderModels(runtime: CliCommandRuntime): Promise<string> {
519
535
  const value = models.map((model) => ({
520
536
  registryKey: model.registryKey,
521
537
  provider: model.provider,
538
+ ...classifyModelProvider(model.provider),
522
539
  id: model.id,
523
540
  displayName: model.displayName,
524
541
  contextWindow: services.providerRegistry.getContextWindowForModel(model),
@@ -526,7 +543,7 @@ async function renderModels(runtime: CliCommandRuntime): Promise<string> {
526
543
  }));
527
544
  return formatJsonOrText(runtime.cli)(value, [
528
545
  `GoodVibes models${filter ? ` (${filter})` : ''}`,
529
- ...value.map((model) => ` ${model.current ? '*' : ' '} ${model.registryKey.padEnd(42)} ctx=${model.contextWindow.toLocaleString()} ${model.displayName}`),
546
+ ...value.map((model) => ` ${model.current ? '*' : ' '} ${model.registryKey.padEnd(42)} setup=${model.setupClass} ctx=${model.contextWindow.toLocaleString()} ${model.displayName}`),
530
547
  ].join('\n'));
531
548
  });
532
549
  }
@@ -622,6 +639,11 @@ export async function handleGoodVibesCliCommand(runtime: CliCommandRuntime): Pro
622
639
  case 'web':
623
640
  console.log(renderWeb(runtime));
624
641
  return { handled: true, exitCode: 0 };
642
+ case 'service': {
643
+ const result = await handleServiceCommand(runtime);
644
+ console.log(result.output);
645
+ return { handled: true, exitCode: result.exitCode };
646
+ }
625
647
  case 'providers': {
626
648
  const output = await renderProviders(runtime);
627
649
  console.log(output);
@@ -659,23 +681,27 @@ export async function handleGoodVibesCliCommand(runtime: CliCommandRuntime): Pro
659
681
  return { handled: true, exitCode: exitCodeForText(output) };
660
682
  }
661
683
  case 'surfaces': {
662
- const output = await handleSurfaces(runtime);
663
- console.log(output);
664
- return { handled: true, exitCode: exitCodeForText(output) };
684
+ const result = await handleSurfacesCommand(runtime);
685
+ console.log(result.output);
686
+ return { handled: true, exitCode: result.exitCode };
687
+ }
688
+ case 'listener': {
689
+ const result = await buildListenerTestResult(runtime);
690
+ console.log(formatListenerTestResult(runtime, result));
691
+ return { handled: true, exitCode: result.issues.length > 0 ? 1 : 0 };
692
+ }
693
+ case 'control-plane': {
694
+ const result = await buildControlPlaneStatusResult(runtime);
695
+ console.log(formatControlPlaneStatus(runtime, result));
696
+ return { handled: true, exitCode: result.issues.length > 0 ? 1 : 0 };
665
697
  }
666
- case 'listener':
667
- console.log(await renderListenerTest(runtime));
668
- return { handled: true, exitCode: 0 };
669
- case 'control-plane':
670
- console.log(await renderControlPlaneStatus(runtime));
671
- return { handled: true, exitCode: 0 };
672
698
  case 'pair':
673
699
  console.log(await renderPairing(runtime));
674
700
  return { handled: true, exitCode: 0 };
675
701
  case 'bundle': {
676
- const output = await handleBundle(runtime);
677
- console.log(output);
678
- return { handled: true, exitCode: exitCodeForText(output) };
702
+ const result = await handleBundleCommand(runtime);
703
+ console.log(result.output);
704
+ return { handled: true, exitCode: result.exitCode };
679
705
  }
680
706
  case 'remote':
681
707
  console.log(await renderRemote(runtime, 'remote'));
@@ -0,0 +1,46 @@
1
+ import type { RuntimeEndpointBinding } from './endpoints.ts';
2
+
3
+ export type BindPostureKind = 'local' | 'local-network' | 'custom-network';
4
+
5
+ export interface BindPosture {
6
+ readonly kind: BindPostureKind;
7
+ readonly label: string;
8
+ readonly networkFacing: boolean;
9
+ }
10
+
11
+ export function isLoopbackHost(host: string): boolean {
12
+ const normalized = host.trim().toLowerCase();
13
+ return normalized === 'localhost'
14
+ || normalized === '::1'
15
+ || normalized === '0:0:0:0:0:0:0:1'
16
+ || normalized.startsWith('127.');
17
+ }
18
+
19
+ export function classifyBindPosture(binding: Pick<RuntimeEndpointBinding, 'hostMode' | 'host'>): BindPosture {
20
+ if (binding.hostMode === 'local' || isLoopbackHost(binding.host)) {
21
+ return {
22
+ kind: 'local',
23
+ label: 'Local only',
24
+ networkFacing: false,
25
+ };
26
+ }
27
+ if (binding.hostMode === 'network' || binding.host === '0.0.0.0' || binding.host === '::') {
28
+ return {
29
+ kind: 'local-network',
30
+ label: 'Local Network',
31
+ networkFacing: true,
32
+ };
33
+ }
34
+ return {
35
+ kind: 'custom-network',
36
+ label: 'Custom network',
37
+ networkFacing: true,
38
+ };
39
+ }
40
+
41
+ export function isNetworkFacing(
42
+ enabled: unknown,
43
+ binding: Pick<RuntimeEndpointBinding, 'hostMode' | 'host'>,
44
+ ): boolean {
45
+ return enabled === true && classifyBindPosture(binding).networkFacing;
46
+ }
@@ -0,0 +1,119 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export interface PackageCliBinVerification {
6
+ readonly command: 'goodvibes' | 'goodvibes-daemon';
7
+ readonly target: string;
8
+ readonly exists: boolean;
9
+ readonly executable: boolean;
10
+ readonly hasLocalPlatformBuildFallback: boolean;
11
+ readonly hasLocalBuildFallback: boolean;
12
+ readonly hasVendoredBinaryFallback: boolean;
13
+ readonly hasSourceFallback: boolean;
14
+ }
15
+
16
+ export interface PackageCliVerificationReport {
17
+ readonly packageName: string;
18
+ readonly version: string;
19
+ readonly bins: readonly PackageCliBinVerification[];
20
+ readonly tarball: {
21
+ readonly entryCount: number;
22
+ readonly unpackedSize: number;
23
+ readonly requiredPathsPresent: readonly string[];
24
+ readonly forbiddenPaths: readonly string[];
25
+ };
26
+ readonly issues: readonly string[];
27
+ }
28
+
29
+ const REQUIRED_BIN_COMMANDS = ['goodvibes', 'goodvibes-daemon'] as const;
30
+ const REQUIRED_TARBALL_PATHS = [
31
+ 'README.md',
32
+ 'CHANGELOG.md',
33
+ 'package.json',
34
+ 'src/main.ts',
35
+ 'src/daemon/cli.ts',
36
+ 'bin/goodvibes',
37
+ 'bin/goodvibes-daemon',
38
+ 'scripts/postinstall.js',
39
+ '.goodvibes/GOODVIBES.md',
40
+ ] as const;
41
+ const FORBIDDEN_TARBALL_PREFIXES = ['.github/', 'src/test/', 'src/.test/', '.goodvibes/memory/', 'vendor/'] as const;
42
+
43
+ function readPackageJson(root: string): Record<string, unknown> {
44
+ return JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8')) as Record<string, unknown>;
45
+ }
46
+
47
+ function hasExecutableBit(path: string): boolean {
48
+ return existsSync(path) && (statSync(path).mode & 0o111) !== 0;
49
+ }
50
+
51
+ function verifyBin(root: string, command: typeof REQUIRED_BIN_COMMANDS[number], target: string | undefined): PackageCliBinVerification {
52
+ const binPath = target ? join(root, target) : '';
53
+ const source = target && existsSync(binPath) ? readFileSync(binPath, 'utf-8') : '';
54
+ const expectedLocalBuild = command === 'goodvibes' ? "dist', 'goodvibes'" : "dist', 'goodvibes-daemon'";
55
+ const expectedSource = command === 'goodvibes' ? "src', 'main.ts'" : "src', 'daemon', 'cli.ts'";
56
+ return {
57
+ command,
58
+ target: target ?? '',
59
+ exists: Boolean(target) && existsSync(binPath),
60
+ executable: Boolean(target) && hasExecutableBit(binPath),
61
+ hasLocalPlatformBuildFallback: source.includes("dist', artifactName"),
62
+ hasLocalBuildFallback: source.includes(expectedLocalBuild),
63
+ hasVendoredBinaryFallback: source.includes('vendor'),
64
+ hasSourceFallback: source.includes(expectedSource) && source.includes("'bun'"),
65
+ };
66
+ }
67
+
68
+ function npmPackDryRun(root: string): { readonly files: readonly string[]; readonly entryCount: number; readonly unpackedSize: number } {
69
+ const raw = execSync('npm pack --json --dry-run', {
70
+ cwd: root,
71
+ encoding: 'utf-8',
72
+ stdio: ['ignore', 'pipe', 'inherit'],
73
+ });
74
+ const [packResult] = JSON.parse(raw) as Array<{ files?: Array<{ path?: string }>; entryCount?: number; unpackedSize?: number }>;
75
+ return {
76
+ files: Array.isArray(packResult?.files) ? packResult.files.map((entry) => String(entry.path ?? '')) : [],
77
+ entryCount: Number(packResult?.entryCount ?? 0),
78
+ unpackedSize: Number(packResult?.unpackedSize ?? 0),
79
+ };
80
+ }
81
+
82
+ export function verifyPackageCliInstall(root: string): PackageCliVerificationReport {
83
+ const pkg = readPackageJson(root);
84
+ const bin = pkg.bin && typeof pkg.bin === 'object' ? pkg.bin as Record<string, string | undefined> : {};
85
+ const bins = REQUIRED_BIN_COMMANDS.map((command) => verifyBin(root, command, bin[command]));
86
+ const pack = npmPackDryRun(root);
87
+ const requiredPathsPresent = REQUIRED_TARBALL_PATHS.filter((path) => pack.files.includes(path));
88
+ const forbiddenPaths = pack.files.filter((path) => FORBIDDEN_TARBALL_PREFIXES.some((prefix) => path.startsWith(prefix)));
89
+ const issues: string[] = [];
90
+
91
+ for (const item of bins) {
92
+ if (!item.target) issues.push(`package.json bin is missing ${item.command}.`);
93
+ if (!item.exists) issues.push(`bin target does not exist: ${item.command} -> ${item.target}`);
94
+ if (!item.executable) issues.push(`bin target is not executable: ${item.command} -> ${item.target}`);
95
+ if (!item.hasLocalPlatformBuildFallback) issues.push(`bin target lacks local platform dist fallback: ${item.command}`);
96
+ if (!item.hasLocalBuildFallback) issues.push(`bin target lacks local dist fallback: ${item.command}`);
97
+ if (!item.hasVendoredBinaryFallback) issues.push(`bin target lacks vendored binary fallback: ${item.command}`);
98
+ if (!item.hasSourceFallback) issues.push(`bin target lacks Bun source fallback: ${item.command}`);
99
+ }
100
+ for (const path of REQUIRED_TARBALL_PATHS) {
101
+ if (!pack.files.includes(path)) issues.push(`npm tarball missing required path: ${path}`);
102
+ }
103
+ for (const path of forbiddenPaths) {
104
+ issues.push(`npm tarball includes forbidden path: ${path}`);
105
+ }
106
+
107
+ return {
108
+ packageName: String(pkg.name ?? ''),
109
+ version: String(pkg.version ?? ''),
110
+ bins,
111
+ tarball: {
112
+ entryCount: pack.entryCount,
113
+ unpackedSize: pack.unpackedSize,
114
+ requiredPathsPresent,
115
+ forbiddenPaths,
116
+ },
117
+ issues,
118
+ };
119
+ }
package/src/cli/parser.ts CHANGED
@@ -15,6 +15,8 @@ const COMMAND_ALIASES: Readonly<Record<string, GoodVibesCliCommand>> = {
15
15
  daemon: 'serve',
16
16
  server: 'serve',
17
17
  web: 'web',
18
+ service: 'service',
19
+ services: 'service',
18
20
  status: 'status',
19
21
  doctor: 'doctor',
20
22
  onboarding: 'onboarding',