@pellux/goodvibes-tui 0.19.24 → 0.19.25

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +5 -5
  3. package/bin/goodvibes +5 -0
  4. package/bin/goodvibes-daemon +5 -0
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +2 -2
  7. package/src/cli/completion.ts +89 -0
  8. package/src/cli/config-overrides.ts +159 -0
  9. package/src/cli/endpoints.ts +63 -0
  10. package/src/cli/entrypoint.ts +155 -0
  11. package/src/cli/help.ts +122 -0
  12. package/src/cli/index.ts +8 -0
  13. package/src/cli/management-commands.ts +576 -0
  14. package/src/cli/management.ts +693 -0
  15. package/src/cli/parser.ts +367 -0
  16. package/src/cli/status.ts +112 -0
  17. package/src/cli/tui-startup.ts +32 -0
  18. package/src/cli/types.ts +63 -0
  19. package/src/cli-flags.ts +17 -55
  20. package/src/config/index.ts +1 -1
  21. package/src/config/secrets.ts +44 -0
  22. package/src/daemon/cli.ts +62 -11
  23. package/src/input/command-registry.ts +3 -0
  24. package/src/input/commands/guidance-runtime.ts +9 -4
  25. package/src/input/commands/local-runtime.ts +21 -7
  26. package/src/input/commands/local-setup.ts +31 -38
  27. package/src/input/commands/onboarding-runtime.ts +14 -0
  28. package/src/input/commands/runtime-services.ts +9 -0
  29. package/src/input/commands.ts +2 -0
  30. package/src/input/feed-context-factory.ts +8 -1
  31. package/src/input/handler-feed.ts +13 -8
  32. package/src/input/handler-interactions.ts +266 -0
  33. package/src/input/handler-modal-stack.ts +23 -3
  34. package/src/input/handler-modal-token-routes.ts +23 -1
  35. package/src/input/handler-onboarding.ts +696 -0
  36. package/src/input/handler-picker-routes.ts +15 -7
  37. package/src/input/handler-ui-state.ts +58 -0
  38. package/src/input/handler.ts +120 -246
  39. package/src/input/onboarding/handler-onboarding-routes.ts +105 -0
  40. package/src/input/onboarding/onboarding-wizard-apply.ts +211 -0
  41. package/src/input/onboarding/onboarding-wizard-constants.ts +148 -0
  42. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +712 -0
  43. package/src/input/onboarding/onboarding-wizard-helpers.ts +218 -0
  44. package/src/input/onboarding/onboarding-wizard-rules.ts +224 -0
  45. package/src/input/onboarding/onboarding-wizard-state.ts +354 -0
  46. package/src/input/onboarding/onboarding-wizard-steps.ts +642 -0
  47. package/src/input/onboarding/onboarding-wizard-types.ts +170 -0
  48. package/src/input/onboarding/onboarding-wizard.ts +594 -0
  49. package/src/main.ts +32 -39
  50. package/src/panels/builtin/operations.ts +0 -10
  51. package/src/panels/index.ts +0 -1
  52. package/src/renderer/conversation-overlays.ts +6 -0
  53. package/src/renderer/help-overlay.ts +1 -1
  54. package/src/renderer/onboarding/onboarding-wizard.ts +533 -0
  55. package/src/runtime/bootstrap-core.ts +1 -0
  56. package/src/runtime/bootstrap.ts +123 -0
  57. package/src/runtime/onboarding/apply.ts +685 -0
  58. package/src/runtime/onboarding/derivation.ts +495 -0
  59. package/src/runtime/onboarding/index.ts +7 -0
  60. package/src/runtime/onboarding/markers.ts +161 -0
  61. package/src/runtime/onboarding/snapshot.ts +400 -0
  62. package/src/runtime/onboarding/state.ts +140 -0
  63. package/src/runtime/onboarding/types.ts +402 -0
  64. package/src/runtime/onboarding/verify.ts +233 -0
  65. package/src/runtime/ui-services.ts +16 -0
  66. package/src/shell/ui-openers.ts +12 -2
  67. package/src/version.ts +1 -1
  68. package/src/panels/welcome-panel.ts +0 -64
@@ -0,0 +1,576 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
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
+ import { SecretsManager } from '../config/secrets.ts';
6
+ import { createShellPathService } from '@pellux/goodvibes-sdk/platform/runtime/shell-paths';
7
+ import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, resolveSecretRef } from '@pellux/goodvibes-sdk/platform/config/secret-refs';
8
+ import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config/subscription-providers';
9
+ import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config/openai-codex-auth';
10
+ import { inspectProviderAuth } from '@pellux/goodvibes-sdk/platform/runtime/auth/inspection';
11
+ import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing/index';
12
+ import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing/qr-generator';
13
+ import { resolveRuntimeEndpointBinding } from './endpoints.ts';
14
+ 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';
16
+
17
+ export async function renderSubscriptions(runtime: CliCommandRuntime): Promise<string> {
18
+ return await withRuntimeServices(runtime, async (services) => {
19
+ const [sub = 'list', ...rest] = runtime.cli.commandArgs;
20
+ const subscriptions = services.subscriptionManager.list();
21
+ const pending = services.subscriptionManager.listPending();
22
+ const available = listAvailableSubscriptionProviders(services.serviceRegistry.getAll());
23
+ if (sub === 'providers') {
24
+ return formatJsonOrText(runtime.cli)(available, [
25
+ 'GoodVibes subscription providers',
26
+ ...available.map((provider) => ` ${provider.provider} source=${provider.source} redirect=${provider.oauth.redirectUri}`),
27
+ ].join('\n'));
28
+ }
29
+ if (sub === 'inspect' || sub === 'show') {
30
+ const provider = rest[0];
31
+ if (!provider) return 'Usage: goodvibes subscription inspect <provider>';
32
+ const resolved = getSubscriptionProviderConfig(provider, services.serviceRegistry.get(provider));
33
+ if (!resolved && !services.subscriptionManager.get(provider) && !services.subscriptionManager.getPending(provider)) {
34
+ return `No stored or available subscription provider named ${provider}.`;
35
+ }
36
+ const inspection = await inspectProviderAuth(provider, {
37
+ serviceRegistry: services.serviceRegistry,
38
+ subscriptionManager: services.subscriptionManager,
39
+ secretsManager: services.secretsManager,
40
+ });
41
+ const stored = services.subscriptionManager.get(provider);
42
+ return formatJsonOrText(runtime.cli)({ provider, resolved, inspection, stored }, [
43
+ `GoodVibes subscription ${provider}`,
44
+ ` configured: ${yesNo(inspection.configured)}`,
45
+ ` freshness: ${inspection.freshness}`,
46
+ ` callbackMode: ${inspection.callbackMode}`,
47
+ ...(resolved ? [
48
+ ` source: ${resolved.source}`,
49
+ ` redirectUri: ${resolved.oauth.redirectUri}`,
50
+ ] : []),
51
+ ...(stored ? [
52
+ ` tokenType: ${stored.tokenType}`,
53
+ ` expiresAt: ${stored.expiresAt ? new Date(stored.expiresAt).toISOString() : 'n/a'}`,
54
+ ` refreshToken: ${stored.refreshToken ? 'present' : 'absent'}`,
55
+ ` overrideAmbient: ${yesNo(stored.overrideAmbientApiKeys)}`,
56
+ ] : [' stored: no']),
57
+ ...inspection.issues.map((issue) => ` issue: ${issue}`),
58
+ ...inspection.nextActions.map((action) => ` next: ${action}`),
59
+ ].join('\n'));
60
+ }
61
+ if (sub === 'login' || sub === 'start') {
62
+ const provider = sub === 'start' ? rest[0] : rest[0];
63
+ const mode = sub === 'start' ? 'start' : rest[1]?.toLowerCase();
64
+ if (!provider || mode !== 'start') return 'Usage: goodvibes subscription login <provider> start [--open]';
65
+ const resolved = getSubscriptionProviderConfig(provider, services.serviceRegistry.get(provider));
66
+ if (!resolved) return `No subscription provider found: ${provider}`;
67
+ if (provider === 'openai' && resolved.source === 'builtin') {
68
+ const started = await beginOpenAICodexLogin();
69
+ services.subscriptionManager.savePending({
70
+ provider,
71
+ state: started.state,
72
+ verifier: started.verifier,
73
+ redirectUri: started.redirectUri,
74
+ createdAt: Date.now(),
75
+ });
76
+ const openResult = runtime.cli.flags.open || hasCommandFlag(rest, '--open') ? openBrowser(started.authorizationUrl) : null;
77
+ return [
78
+ `Subscription OAuth started: ${provider}`,
79
+ ` source: ${resolved.source}`,
80
+ ` state: ${started.state}`,
81
+ ` redirectUri: ${started.redirectUri}`,
82
+ ...(openResult ? [` open: ${openResult}`] : []),
83
+ ` next: goodvibes subscription login ${provider} finish <code-or-url>`,
84
+ ' authorizationUrl:',
85
+ ` ${started.authorizationUrl}`,
86
+ ].join('\n');
87
+ }
88
+ const started = await services.subscriptionManager.beginOAuthLogin(provider, resolved.oauth);
89
+ const openResult = runtime.cli.flags.open || hasCommandFlag(rest, '--open') ? openBrowser(started.authorizationUrl) : null;
90
+ return [
91
+ `Subscription OAuth started: ${provider}`,
92
+ ` source: ${resolved.source}`,
93
+ ` state: ${started.pending.state}`,
94
+ ` redirectUri: ${started.pending.redirectUri}`,
95
+ ...(openResult ? [` open: ${openResult}`] : []),
96
+ ` next: goodvibes subscription login ${provider} finish <code-or-url>`,
97
+ ' authorizationUrl:',
98
+ ` ${started.authorizationUrl}`,
99
+ ].join('\n');
100
+ }
101
+ if (sub === 'finish' || (sub === 'login' && rest[1]?.toLowerCase() === 'finish')) {
102
+ const provider = sub === 'finish' ? rest[0] : rest[0];
103
+ const codeInput = sub === 'finish' ? rest[1] : rest[2];
104
+ if (!provider || !codeInput) return 'Usage: goodvibes subscription login <provider> finish <code-or-url>';
105
+ const resolved = getSubscriptionProviderConfig(provider, services.serviceRegistry.get(provider));
106
+ if (!resolved) return `No subscription provider found: ${provider}`;
107
+ const code = extractAuthorizationCode(codeInput);
108
+ if (provider === 'openai' && resolved.source === 'builtin') {
109
+ const pendingLogin = services.subscriptionManager.getPending(provider);
110
+ if (!pendingLogin) return `No pending OAuth login for ${provider}.`;
111
+ const token = await exchangeOpenAICodexCode(code, pendingLogin.verifier);
112
+ const now = Date.now();
113
+ const record = services.subscriptionManager.saveSubscription({
114
+ provider,
115
+ accessToken: token.accessToken,
116
+ refreshToken: token.refreshToken,
117
+ tokenType: token.tokenType,
118
+ expiresAt: token.expiresAt,
119
+ ...(token.scopes ? { scopes: token.scopes } : {}),
120
+ authMode: 'oauth',
121
+ overrideAmbientApiKeys: false,
122
+ createdAt: services.subscriptionManager.get(provider)?.createdAt ?? now,
123
+ updatedAt: now,
124
+ });
125
+ return `Subscription stored: ${provider} token=${record.tokenType} expires=${record.expiresAt ? new Date(record.expiresAt).toISOString() : 'n/a'}`;
126
+ }
127
+ const record = await services.subscriptionManager.completeOAuthLogin(provider, resolved.oauth, code);
128
+ return `Subscription stored: ${provider} token=${record.tokenType} expires=${record.expiresAt ? new Date(record.expiresAt).toISOString() : 'n/a'}`;
129
+ }
130
+ if (sub === 'refresh') {
131
+ const provider = rest[0];
132
+ if (!provider) return 'Usage: goodvibes subscription refresh <provider>';
133
+ const resolved = getSubscriptionProviderConfig(provider, services.serviceRegistry.get(provider));
134
+ if (!resolved) return `No subscription provider found: ${provider}`;
135
+ const record = await services.subscriptionManager.refreshOAuthToken(provider, resolved.oauth);
136
+ return `Subscription refreshed: ${provider} expires=${record.expiresAt ? new Date(record.expiresAt).toISOString() : 'n/a'}`;
137
+ }
138
+ if (sub === 'logout' || sub === 'remove') {
139
+ const provider = rest[0];
140
+ if (!provider) return 'Usage: goodvibes subscription logout <provider>';
141
+ const removed = services.subscriptionManager.logout(provider);
142
+ return removed ? `Subscription removed: ${provider}` : `No stored subscription session existed for ${provider}.`;
143
+ }
144
+ if (sub !== 'list' && sub !== 'status' && sub !== 'review') {
145
+ return 'Usage: goodvibes subscription [list|providers|inspect <provider>|login <provider> start|finish <code-or-url>|refresh <provider>|logout <provider>]';
146
+ }
147
+ const value = {
148
+ subscriptions: subscriptions.map((sub) => ({
149
+ provider: sub.provider,
150
+ tokenType: sub.tokenType,
151
+ expiresAt: sub.expiresAt ?? null,
152
+ overrideAmbientApiKeys: sub.overrideAmbientApiKeys,
153
+ })),
154
+ pending: pending.map((sub) => ({ provider: sub.provider, createdAt: sub.createdAt })),
155
+ };
156
+ return formatJsonOrText(runtime.cli)(value, [
157
+ 'GoodVibes subscriptions',
158
+ subscriptions.length === 0 ? ' active: none' : ' active:',
159
+ ...subscriptions.map((sub) => ` ${sub.provider} token=${sub.tokenType} expires=${sub.expiresAt ? new Date(sub.expiresAt).toISOString() : 'n/a'} overrideAmbient=${yesNo(sub.overrideAmbientApiKeys)}`),
160
+ pending.length === 0 ? ' pending: none' : ' pending:',
161
+ ...pending.map((sub) => ` ${sub.provider} created=${new Date(sub.createdAt).toISOString()}`),
162
+ ].join('\n'));
163
+ });
164
+ }
165
+
166
+ export async function handleSecrets(runtime: CliCommandRuntime): Promise<string> {
167
+ const secrets = new SecretsManager({
168
+ projectRoot: runtime.workingDirectory,
169
+ globalHome: runtime.homeDirectory,
170
+ configManager: runtime.configManager,
171
+ });
172
+ const [sub = 'list', ...rest] = runtime.cli.commandArgs;
173
+ if (sub === 'providers') {
174
+ const value = { providers: BUILTIN_SECRET_PROVIDER_SOURCES };
175
+ return formatJsonOrText(runtime.cli)(value, [
176
+ 'GoodVibes secret providers',
177
+ ...BUILTIN_SECRET_PROVIDER_SOURCES.map((provider) => ` ${provider}`),
178
+ '',
179
+ 'Secret refs use goodvibes://secrets/<source>/... and never embed secret values.',
180
+ ].join('\n'));
181
+ }
182
+ if (sub === 'test') {
183
+ const ref = rest.join(' ').trim();
184
+ if (!ref || !ref.startsWith('goodvibes://secrets/') || !isSecretRefInput(ref)) {
185
+ return 'Usage: goodvibes secrets test goodvibes://secrets/<source>/...';
186
+ }
187
+ const resolved = await resolveSecretRef(ref, { resolveLocalSecret: (key) => secrets.get(key) });
188
+ const value = { ref: describeSecretRef(ref), resolved: Boolean(resolved.value) };
189
+ return formatJsonOrText(runtime.cli)(value, `[secrets] ${value.ref}: ${value.resolved ? 'resolved <redacted>' : 'missing'}`);
190
+ }
191
+ if (sub === 'set' || sub === 'link') {
192
+ const flags = new Set(rest.filter((arg) => arg.startsWith('--')));
193
+ const values = rest.filter((arg) => !arg.startsWith('--'));
194
+ const [key, ...rawValueParts] = values;
195
+ const value = rawValueParts.join(' ');
196
+ if (!key || !value) return `Usage: goodvibes secrets ${sub} <KEY> <value> [--user|--project] [--secure|--plaintext]`;
197
+ if (sub === 'link' && (!value.startsWith('goodvibes://secrets/') || !isSecretRefInput(value))) {
198
+ return 'Invalid secret reference. Use goodvibes://secrets/<source>/...';
199
+ }
200
+ await secrets.set(key, value, {
201
+ scope: flags.has('--user') ? 'user' : 'project',
202
+ medium: flags.has('--plaintext') ? 'plaintext' : 'secure',
203
+ });
204
+ return `[secrets] ${sub === 'link' ? 'Linked' : 'Stored'}: ${key}`;
205
+ }
206
+ if (sub === 'delete') {
207
+ const key = rest.find((arg) => !arg.startsWith('--'));
208
+ if (!key) return 'Usage: goodvibes secrets delete <KEY> [--user|--project] [--secure|--plaintext]';
209
+ const flags = new Set(rest.filter((arg) => arg.startsWith('--')));
210
+ await secrets.delete(key, {
211
+ scope: flags.has('--user') ? 'user' : flags.has('--project') ? 'project' : undefined,
212
+ medium: flags.has('--secure') ? 'secure' : flags.has('--plaintext') ? 'plaintext' : undefined,
213
+ });
214
+ return `[secrets] Deleted: ${key}`;
215
+ }
216
+ const [records, review] = await Promise.all([secrets.listDetailed(), secrets.inspect()]);
217
+ const stored = records.filter((record) => record.source !== 'env');
218
+ const value = { policy: review.policy, records: stored, warnings: review.warnings };
219
+ return formatJsonOrText(runtime.cli)(value, [
220
+ 'GoodVibes secrets',
221
+ ` policy: ${review.policy}`,
222
+ ` secure available: ${yesNo(review.secureAvailable)}`,
223
+ ` stored keys: ${stored.length}`,
224
+ ...stored.map((record) => ` ${record.key} (${record.source}${record.refSource ? `, ref:${record.refSource}` : ''}${record.overriddenByEnv ? ', env override' : ''})`),
225
+ ...review.warnings.map((warning) => ` warning: ${warning}`),
226
+ ].join('\n'));
227
+ }
228
+
229
+ export async function handleSessions(runtime: CliCommandRuntime): Promise<string | null> {
230
+ return await withRuntimeServices(runtime, (services) => {
231
+ const [sub = 'list', ...rest] = runtime.cli.commandArgs;
232
+ const sessions = services.sessionManager.list();
233
+ if (sub === 'list') {
234
+ const value = sessions;
235
+ return formatJsonOrText(runtime.cli)(value, [
236
+ `GoodVibes sessions (${sessions.length})`,
237
+ ...sessions.slice(0, 50).map((session) => ` ${session.name} messages=${session.messageCount} ${new Date(session.timestamp).toISOString()} ${session.title || '(untitled)'}`),
238
+ ].join('\n'));
239
+ }
240
+ if (sub === 'show' || sub === 'info') {
241
+ const target = rest.join(' ').trim();
242
+ if (!target) return 'Usage: goodvibes sessions show <id|name>';
243
+ const found = sessions.find((session) => session.name === target || session.name.startsWith(target) || session.title.toLowerCase() === target.toLowerCase());
244
+ if (!found) return `Session not found: ${target}`;
245
+ return formatJsonOrText(runtime.cli)(found, [
246
+ `Session ${found.name}`,
247
+ ` title: ${found.title || '(untitled)'}`,
248
+ ` messages: ${found.messageCount}`,
249
+ ` provider/model: ${found.provider}/${found.model}`,
250
+ ` updated: ${new Date(found.timestamp).toISOString()}`,
251
+ ` file: ${found.filePath}`,
252
+ ].join('\n'));
253
+ }
254
+ if (sub === 'export') {
255
+ const target = rest[0];
256
+ const outputPath = rest[1];
257
+ if (!target) return 'Usage: goodvibes sessions export <id|name> [path]';
258
+ const found = sessions.find((session) => session.name === target || session.name.startsWith(target) || session.title.toLowerCase() === target.toLowerCase());
259
+ if (!found) return `Session not found: ${target}`;
260
+ const data = services.sessionManager.load(found.name);
261
+ const text = JSON.stringify({ name: found.name, ...data }, null, 2) + '\n';
262
+ if (outputPath) {
263
+ const targetPath = services.shellPaths.resolveWorkspacePath(outputPath);
264
+ mkdirSync(dirname(targetPath), { recursive: true });
265
+ writeFileSync(targetPath, text, 'utf-8');
266
+ return `Session exported: ${targetPath}`;
267
+ }
268
+ return text.trimEnd();
269
+ }
270
+ if (sub === 'resume') {
271
+ const target = rest.join(' ').trim();
272
+ return target ? null : 'Usage: goodvibes sessions resume <id|name>';
273
+ }
274
+ return 'Usage: goodvibes sessions list|show <id>|export <id> [path]|resume <id>';
275
+ });
276
+ }
277
+
278
+ export async function handleTasks(runtime: CliCommandRuntime): Promise<string> {
279
+ const [sub = 'list', ...rest] = runtime.cli.commandArgs;
280
+ if (sub === 'submit') {
281
+ const prompt = rest.join(' ').trim();
282
+ if (!prompt) return 'Usage: goodvibes tasks submit <prompt>';
283
+ const runCli = {
284
+ ...runtime.cli,
285
+ command: 'run' as const,
286
+ flags: { ...runtime.cli.flags, prompt },
287
+ positionals: [prompt],
288
+ };
289
+ const code = await runNonInteractiveAgent({ ...runtime, cli: runCli });
290
+ return code === 0 ? '' : `Task submit failed with exit code ${code}`;
291
+ }
292
+ return await withRuntimeServices(runtime, (services) => {
293
+ const tasks = [...services.runtimeStore.getState().tasks.tasks.values()];
294
+ if (sub === 'list') {
295
+ return tasks.length === 0
296
+ ? 'GoodVibes tasks\n No in-process runtime tasks are currently recorded.'
297
+ : ['GoodVibes tasks', ...tasks.map((task) => ` ${task.id} ${task.status} ${task.kind} ${task.title}`)].join('\n');
298
+ }
299
+ if (sub === 'show') {
300
+ if (!rest[0]) return 'Usage: goodvibes tasks show <taskId>';
301
+ const task = tasks.find((candidate) => candidate.id === rest[0]);
302
+ return task ? JSON.stringify(task, null, 2) : `Unknown task: ${rest[0] ?? ''}`;
303
+ }
304
+ return 'Usage: goodvibes tasks list|show <taskId>|submit <prompt>';
305
+ });
306
+ }
307
+
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,
413
+ };
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'));
423
+ }
424
+
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> {
439
+ const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
440
+ const reachable = await probeTcp(binding.host, binding.port);
441
+ const auth = readAuthPaths(runtime);
442
+ const value = {
443
+ enabled: runtime.configManager.get('controlPlane.enabled'),
444
+ ...binding,
445
+ reachable,
446
+ auth,
447
+ };
448
+ return formatJsonOrText(runtime.cli)(value, [
449
+ 'GoodVibes control-plane status',
450
+ ` 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'}`,
456
+ ].join('\n'));
457
+ }
458
+
459
+ export async function renderPairing(runtime: CliCommandRuntime): Promise<string> {
460
+ const daemonHomeDir = join(runtime.homeDirectory, '.goodvibes', 'daemon');
461
+ const tokenRecord = getOrCreateCompanionToken('tui', { daemonHomeDir });
462
+ const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'controlPlane');
463
+ const daemonUrl = `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
464
+ const info = buildCompanionConnectionInfo({
465
+ daemonUrl,
466
+ token: tokenRecord.token,
467
+ username: 'admin',
468
+ });
469
+ const payload = encodeConnectionPayload(info);
470
+ const qr = renderQrToString(generateQrMatrix(payload));
471
+ return [formatConnectionBlock(info, payload), '', qr].join('\n');
472
+ }
473
+
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
+ export async function renderRemote(runtime: CliCommandRuntime, label: 'remote' | 'bridge'): Promise<string> {
537
+ return await withRuntimeServices(runtime, (services) => {
538
+ const pools = services.remoteRunnerRegistry.listPools?.() ?? [];
539
+ const contracts = services.remoteRunnerRegistry.listContracts();
540
+ const artifacts = services.remoteRunnerRegistry.listArtifacts();
541
+ const value = {
542
+ pools: pools.length,
543
+ contracts: contracts.length,
544
+ artifacts: artifacts.length,
545
+ remoteFetchPrivateHosts: runtime.configManager.get('network.remoteFetch.allowPrivateHosts'),
546
+ };
547
+ return formatJsonOrText(runtime.cli)(value, [
548
+ `GoodVibes ${label} status`,
549
+ ` runner pools: ${value.pools}`,
550
+ ` contracts: ${value.contracts}`,
551
+ ` review artifacts: ${value.artifacts}`,
552
+ ` private-host remote fetch: ${yesNo(value.remoteFetchPrivateHosts)}`,
553
+ ].join('\n'));
554
+ });
555
+ }
556
+
557
+ export function renderWeb(runtime: CliCommandRuntime): string {
558
+ const binding = resolveRuntimeEndpointBinding(runtime.configManager, 'web');
559
+ const publicBaseUrl = String(runtime.configManager.get('web.publicBaseUrl') ?? '');
560
+ const hasEndpointOverride = runtime.cli.flags.hostname !== undefined || runtime.cli.flags.port !== undefined;
561
+ const url = !hasEndpointOverride && publicBaseUrl
562
+ ? publicBaseUrl
563
+ : `http://${urlHostForBindHost(binding.host)}:${binding.port}`;
564
+ const value = {
565
+ enabled: runtime.configManager.get('web.enabled'),
566
+ ...binding,
567
+ url,
568
+ };
569
+ return formatJsonOrText(runtime.cli)(value, [
570
+ 'GoodVibes web',
571
+ ` enabled: ${yesNo(value.enabled)}`,
572
+ ` bind: ${value.hostMode} ${value.host}:${value.port}`,
573
+ ` url: ${value.url}`,
574
+ ...(runtime.cli.flags.open ? [` open: ${openBrowser(value.url)}`] : []),
575
+ ].join('\n'));
576
+ }