@pellux/goodvibes-tui 0.19.24 → 0.19.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +5 -5
  3. package/bin/goodvibes +10 -0
  4. package/bin/goodvibes-daemon +10 -0
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +3 -2
  7. package/src/cli/bundle-command.ts +225 -0
  8. package/src/cli/completion.ts +90 -0
  9. package/src/cli/config-overrides.ts +159 -0
  10. package/src/cli/endpoints.ts +63 -0
  11. package/src/cli/entrypoint.ts +169 -0
  12. package/src/cli/help.ts +301 -0
  13. package/src/cli/index.ts +11 -0
  14. package/src/cli/management-commands.ts +426 -0
  15. package/src/cli/management.ts +719 -0
  16. package/src/cli/network-posture.ts +46 -0
  17. package/src/cli/package-verification.ts +119 -0
  18. package/src/cli/parser.ts +369 -0
  19. package/src/cli/provider-classification.ts +107 -0
  20. package/src/cli/redaction.ts +105 -0
  21. package/src/cli/service-command.ts +45 -0
  22. package/src/cli/service-posture.ts +247 -0
  23. package/src/cli/status.ts +382 -0
  24. package/src/cli/surface-command.ts +248 -0
  25. package/src/cli/tui-startup.ts +32 -0
  26. package/src/cli/types.ts +69 -0
  27. package/src/cli-flags.ts +18 -55
  28. package/src/config/index.ts +1 -1
  29. package/src/config/secrets.ts +44 -0
  30. package/src/daemon/cli.ts +62 -11
  31. package/src/input/command-registry.ts +3 -0
  32. package/src/input/commands/guidance-runtime.ts +9 -4
  33. package/src/input/commands/local-runtime.ts +21 -7
  34. package/src/input/commands/local-setup.ts +31 -38
  35. package/src/input/commands/onboarding-runtime.ts +14 -0
  36. package/src/input/commands/runtime-services.ts +9 -0
  37. package/src/input/commands.ts +2 -0
  38. package/src/input/feed-context-factory.ts +8 -1
  39. package/src/input/handler-feed.ts +13 -8
  40. package/src/input/handler-interactions.ts +266 -0
  41. package/src/input/handler-modal-stack.ts +23 -3
  42. package/src/input/handler-modal-token-routes.ts +23 -1
  43. package/src/input/handler-onboarding.ts +696 -0
  44. package/src/input/handler-picker-routes.ts +15 -7
  45. package/src/input/handler-ui-state.ts +58 -0
  46. package/src/input/handler.ts +120 -246
  47. package/src/input/onboarding/handler-onboarding-routes.ts +105 -0
  48. package/src/input/onboarding/onboarding-wizard-apply.ts +211 -0
  49. package/src/input/onboarding/onboarding-wizard-constants.ts +148 -0
  50. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +712 -0
  51. package/src/input/onboarding/onboarding-wizard-helpers.ts +218 -0
  52. package/src/input/onboarding/onboarding-wizard-rules.ts +224 -0
  53. package/src/input/onboarding/onboarding-wizard-state.ts +354 -0
  54. package/src/input/onboarding/onboarding-wizard-steps.ts +642 -0
  55. package/src/input/onboarding/onboarding-wizard-types.ts +170 -0
  56. package/src/input/onboarding/onboarding-wizard.ts +594 -0
  57. package/src/main.ts +32 -39
  58. package/src/panels/builtin/operations.ts +0 -10
  59. package/src/panels/index.ts +0 -1
  60. package/src/renderer/conversation-overlays.ts +6 -0
  61. package/src/renderer/help-overlay.ts +1 -1
  62. package/src/renderer/onboarding/onboarding-wizard.ts +533 -0
  63. package/src/runtime/bootstrap-core.ts +1 -0
  64. package/src/runtime/bootstrap.ts +123 -0
  65. package/src/runtime/onboarding/apply.ts +685 -0
  66. package/src/runtime/onboarding/derivation.ts +495 -0
  67. package/src/runtime/onboarding/index.ts +7 -0
  68. package/src/runtime/onboarding/markers.ts +161 -0
  69. package/src/runtime/onboarding/snapshot.ts +400 -0
  70. package/src/runtime/onboarding/state.ts +140 -0
  71. package/src/runtime/onboarding/types.ts +402 -0
  72. package/src/runtime/onboarding/verify.ts +233 -0
  73. package/src/runtime/ui-services.ts +16 -0
  74. package/src/shell/ui-openers.ts +12 -2
  75. package/src/version.ts +1 -1
  76. package/src/panels/welcome-panel.ts +0 -64
@@ -0,0 +1,495 @@
1
+ import { DEFAULT_CONFIG } from '../../config/index.ts';
2
+ import type {
3
+ OnboardingAcknowledgementState,
4
+ OnboardingAcknowledgementTarget,
5
+ OnboardingNetworkMode,
6
+ OnboardingReopenEditAcknowledgementState,
7
+ OnboardingSnapshotState,
8
+ OnboardingStep1CapabilityItem,
9
+ OnboardingStepDerivationState,
10
+ } from './types.ts';
11
+
12
+ const PROVIDER_SECRET_ENV_ALIASES = {
13
+ openai: ['OPENAI_API_KEY', 'OPENAI_KEY'],
14
+ anthropic: ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'],
15
+ gemini: ['GEMINI_API_KEY', 'GOOGLE_API_KEY', 'GOOGLE_GEMINI_API_KEY'],
16
+ inceptionlabs: ['INCEPTION_API_KEY'],
17
+ openrouter: ['OPENROUTER_API_KEY'],
18
+ aihubmix: ['AIHUBMIX_API_KEY'],
19
+ groq: ['GROQ_API_KEY'],
20
+ cerebras: ['CEREBRAS_API_KEY'],
21
+ mistral: ['MISTRAL_API_KEY'],
22
+ 'ollama-cloud': ['OLLAMA_CLOUD_API_KEY', 'OLLAMA_API_KEY'],
23
+ huggingface: ['HF_API_KEY', 'HUGGINGFACE_API_KEY', 'HF_TOKEN'],
24
+ nvidia: ['NVIDIA_API_KEY'],
25
+ llm7: ['LLM7_API_KEY'],
26
+ deepseek: ['DEEPSEEK_API_KEY'],
27
+ fireworks: ['FIREWORKS_API_KEY'],
28
+ 'github-copilot': ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'],
29
+ 'microsoft-foundry': ['AZURE_OPENAI_API_KEY'],
30
+ minimax: ['MINIMAX_API_KEY'],
31
+ moonshot: ['MOONSHOT_API_KEY'],
32
+ qianfan: ['QIANFAN_API_KEY'],
33
+ qwen: ['QWEN_API_KEY', 'DASHSCOPE_API_KEY', 'MODELSTUDIO_API_KEY'],
34
+ sglang: ['SGLANG_API_KEY'],
35
+ stepfun: ['STEPFUN_API_KEY'],
36
+ together: ['TOGETHER_API_KEY'],
37
+ venice: ['VENICE_API_KEY'],
38
+ volcengine: ['VOLCANO_ENGINE_API_KEY'],
39
+ xai: ['XAI_API_KEY'],
40
+ xiaomi: ['XIAOMI_API_KEY'],
41
+ zai: ['ZAI_API_KEY', 'Z_AI_API_KEY'],
42
+ 'cloudflare-ai-gateway': ['CLOUDFLARE_AI_GATEWAY_API_KEY'],
43
+ 'vercel-ai-gateway': ['AI_GATEWAY_API_KEY'],
44
+ litellm: ['LITELLM_API_KEY'],
45
+ 'copilot-proxy': ['COPILOT_PROXY_API_KEY'],
46
+ } as const satisfies Record<string, readonly string[]>;
47
+
48
+ const SECRET_KEY_TO_PROVIDER_IDS = new Map<string, readonly string[]>(
49
+ Object.entries(PROVIDER_SECRET_ENV_ALIASES).flatMap(([providerId, aliases]) => aliases.map((alias) => [alias, [providerId] as const])),
50
+ );
51
+
52
+ const INBOUND_EVENT_SURFACE_KINDS = new Set<string>([
53
+ 'bluebubbles',
54
+ 'discord',
55
+ 'google-chat',
56
+ 'googleChat',
57
+ 'imessage',
58
+ 'mattermost',
59
+ 'matrix',
60
+ 'msteams',
61
+ 'ntfy',
62
+ 'signal',
63
+ 'slack',
64
+ 'telegram',
65
+ 'webhook',
66
+ 'whatsapp',
67
+ ]);
68
+
69
+ function isDeepEqual(left: unknown, right: unknown): boolean {
70
+ if (Object.is(left, right)) return true;
71
+ if (Array.isArray(left) && Array.isArray(right)) {
72
+ return left.length === right.length && left.every((value, index) => isDeepEqual(value, right[index]));
73
+ }
74
+
75
+ if (
76
+ typeof left === 'object' && left !== null
77
+ && typeof right === 'object' && right !== null
78
+ && !Array.isArray(left)
79
+ && !Array.isArray(right)
80
+ ) {
81
+ const leftEntries = Object.entries(left);
82
+ const rightEntries = Object.entries(right);
83
+ if (leftEntries.length !== rightEntries.length) return false;
84
+
85
+ return leftEntries.every(([key, value]) => isDeepEqual(value, (right as Record<string, unknown>)[key]));
86
+ }
87
+
88
+ return false;
89
+ }
90
+
91
+ function countPermissionToolOverrides(snapshot: OnboardingSnapshotState): number {
92
+ return Object.entries(snapshot.config.permissions.tools).filter(([key, value]) => {
93
+ if (value === undefined) return false;
94
+ return value !== DEFAULT_CONFIG.permissions.tools[key as keyof typeof DEFAULT_CONFIG.permissions.tools];
95
+ }).length;
96
+ }
97
+
98
+ function hasCustomizedProviderRouting(snapshot: OnboardingSnapshotState): boolean {
99
+ return snapshot.providerRouting.primaryProviderId !== DEFAULT_CONFIG.provider.provider
100
+ || snapshot.providerRouting.primaryModelId !== DEFAULT_CONFIG.provider.model
101
+ || snapshot.providerRouting.primaryReasoningEffort !== DEFAULT_CONFIG.provider.reasoningEffort
102
+ || snapshot.providerRouting.embeddingProviderId !== DEFAULT_CONFIG.provider.embeddingProvider
103
+ || snapshot.providerRouting.systemPromptFile.trim() !== DEFAULT_CONFIG.provider.systemPromptFile.trim()
104
+ || snapshot.providerRouting.helperEnabled !== DEFAULT_CONFIG.helper.enabled
105
+ || snapshot.providerRouting.helperProviderId !== DEFAULT_CONFIG.helper.globalProvider
106
+ || snapshot.providerRouting.helperModelId !== DEFAULT_CONFIG.helper.globalModel
107
+ || snapshot.providerRouting.toolLlmEnabled !== DEFAULT_CONFIG.tools.llmEnabled
108
+ || snapshot.providerRouting.toolProviderId !== DEFAULT_CONFIG.tools.llmProvider
109
+ || snapshot.providerRouting.toolModelId !== DEFAULT_CONFIG.tools.llmModel;
110
+ }
111
+
112
+ function getProviderAccountSignalIds(snapshot: OnboardingSnapshotState): string[] {
113
+ return (snapshot.providerAccounts?.providers ?? [])
114
+ .filter((provider) => provider.activeRoute !== 'unconfigured' || provider.pendingLogin || provider.oauthReady)
115
+ .map((provider) => provider.providerId);
116
+ }
117
+
118
+ function getServiceCredentialProviderIds(snapshot: OnboardingSnapshotState): string[] {
119
+ return snapshot.services.services
120
+ .filter((service) => service.hasPrimaryCredential || service.hasPasswordCredential)
121
+ .map((service) => service.providerId);
122
+ }
123
+
124
+ function getSecretBackedProviderIds(snapshot: OnboardingSnapshotState): string[] {
125
+ const providerIds = new Set<string>();
126
+
127
+ for (const record of snapshot.secrets.records) {
128
+ const matches = SECRET_KEY_TO_PROVIDER_IDS.get(record.key);
129
+ if (!matches) continue;
130
+ for (const providerId of matches) providerIds.add(providerId);
131
+ }
132
+
133
+ return [...providerIds].sort((left, right) => left.localeCompare(right));
134
+ }
135
+
136
+ function getConfiguredProviderSignalIds(snapshot: OnboardingSnapshotState): string[] {
137
+ return [...new Set<string>([
138
+ ...getProviderAccountSignalIds(snapshot),
139
+ ...snapshot.services.oauthProviderIds,
140
+ ...getServiceCredentialProviderIds(snapshot),
141
+ ...snapshot.subscriptions.activeProviderIds,
142
+ ...snapshot.subscriptions.pendingProviderIds,
143
+ ...getSecretBackedProviderIds(snapshot),
144
+ ])].sort((left, right) => left.localeCompare(right));
145
+ }
146
+
147
+ function hasConfiguredProviderState(snapshot: OnboardingSnapshotState): boolean {
148
+ return getConfiguredProviderSignalIds(snapshot).length > 0;
149
+ }
150
+
151
+ function countConfiguredSurfaceKinds(snapshot: OnboardingSnapshotState): number {
152
+ return new Set<string>([
153
+ ...snapshot.surfaces.configuredEnabledKinds,
154
+ ...snapshot.surfaces.records.filter((surface) => surface.enabled).map((surface) => surface.kind),
155
+ ]).size;
156
+ }
157
+
158
+ function hasInboundEventSurface(snapshot: OnboardingSnapshotState): boolean {
159
+ return snapshot.surfaces.configuredEnabledKinds.some((kind) => INBOUND_EVENT_SURFACE_KINDS.has(kind))
160
+ || snapshot.surfaces.records.some((surface) => surface.enabled && INBOUND_EVENT_SURFACE_KINDS.has(surface.kind));
161
+ }
162
+
163
+ function hasCustomizedWorkspaceDefaults(snapshot: OnboardingSnapshotState): boolean {
164
+ return !isDeepEqual(snapshot.config.behavior, DEFAULT_CONFIG.behavior)
165
+ || !isDeepEqual(snapshot.config.display, DEFAULT_CONFIG.display);
166
+ }
167
+
168
+ function hasAnyServerEnabled(snapshot: OnboardingSnapshotState): boolean {
169
+ return snapshot.bindSettings.daemonEnabled
170
+ || snapshot.bindSettings.controlPlane.enabled
171
+ || snapshot.bindSettings.httpListenerEnabled
172
+ || snapshot.bindSettings.web.enabled;
173
+ }
174
+
175
+ function hasBrowserAccess(snapshot: OnboardingSnapshotState): boolean {
176
+ return snapshot.bindSettings.web.enabled;
177
+ }
178
+
179
+ function isLoopbackHost(host: string | null | undefined): boolean {
180
+ const normalized = (host ?? '').trim().toLowerCase();
181
+ if (normalized.length === 0) return false;
182
+ return normalized === 'localhost'
183
+ || normalized === '::1'
184
+ || normalized === '[::1]'
185
+ || normalized === '0:0:0:0:0:0:0:1'
186
+ || /^127(?:\.\d{1,3}){3}$/.test(normalized);
187
+ }
188
+
189
+ function isRemoteBind(hostMode: string, host: string | null | undefined, allowRemote = false): boolean {
190
+ if (hostMode === 'network') return true;
191
+ if (hostMode === 'local') return allowRemote;
192
+ if (hostMode === 'custom') return !isLoopbackHost(host);
193
+ return false;
194
+ }
195
+
196
+ function hasRemoteDeviceAccess(snapshot: OnboardingSnapshotState): boolean {
197
+ return (
198
+ ((snapshot.bindSettings.daemonEnabled || snapshot.bindSettings.controlPlane.enabled)
199
+ && isRemoteBind(
200
+ snapshot.bindSettings.controlPlane.hostMode,
201
+ snapshot.bindSettings.controlPlane.host,
202
+ snapshot.bindSettings.controlPlane.allowRemote,
203
+ ))
204
+ || (snapshot.bindSettings.web.enabled && isRemoteBind(
205
+ snapshot.bindSettings.web.hostMode,
206
+ snapshot.bindSettings.web.host,
207
+ ))
208
+ );
209
+ }
210
+
211
+ function hasWebhookOrEventIngress(snapshot: OnboardingSnapshotState): boolean {
212
+ return snapshot.bindSettings.httpListenerEnabled
213
+ || hasInboundEventSurface(snapshot)
214
+ || snapshot.services.services.some((service) => service.hasWebhookUrl || service.hasSigningSecret || service.hasPublicKey);
215
+ }
216
+
217
+ function getProviderIdentityIds(snapshot: OnboardingSnapshotState): Set<string> {
218
+ return new Set<string>([
219
+ ...Object.keys(PROVIDER_SECRET_ENV_ALIASES),
220
+ ...getConfiguredProviderSignalIds(snapshot),
221
+ snapshot.providerRouting.primaryProviderId,
222
+ snapshot.providerRouting.embeddingProviderId,
223
+ snapshot.providerRouting.helperProviderId,
224
+ snapshot.providerRouting.toolProviderId,
225
+ ].filter((value) => value.trim().length > 0));
226
+ }
227
+
228
+ function getExternalIntegrationServiceIds(snapshot: OnboardingSnapshotState): string[] {
229
+ const providerIdentityIds = getProviderIdentityIds(snapshot);
230
+
231
+ return snapshot.services.services
232
+ .filter((service) => !providerIdentityIds.has(service.providerId) && !providerIdentityIds.has(service.name))
233
+ .map((service) => service.name);
234
+ }
235
+
236
+ function hasExternalIntegrations(snapshot: OnboardingSnapshotState): boolean {
237
+ return getExternalIntegrationServiceIds(snapshot).length > 0
238
+ || countConfiguredSurfaceKinds(snapshot) > 0;
239
+ }
240
+
241
+ function describeLocalTuiOnly(snapshot: OnboardingSnapshotState): string {
242
+ if (!hasAnyServerEnabled(snapshot)) {
243
+ return 'Keep GoodVibes in this terminal and disable browser access, background services, network listeners, and external surfaces.';
244
+ }
245
+
246
+ return 'Switching to this disables browser access, background services, network listeners, and external surfaces.';
247
+ }
248
+
249
+ function describeBrowserAccess(snapshot: OnboardingSnapshotState): string {
250
+ return snapshot.bindSettings.web.enabled
251
+ ? 'Keep the background service and web UI enabled, reachable according to the network step.'
252
+ : 'Enable the background service and web UI, reachable on the local network by default unless customized.';
253
+ }
254
+
255
+ function describeRemoteDeviceAccess(snapshot: OnboardingSnapshotState): string {
256
+ return hasRemoteDeviceAccess(snapshot)
257
+ ? 'Keep enabled GoodVibes services reachable from other devices on your LAN. Local auth is required.'
258
+ : 'Expose enabled GoodVibes services on your LAN so other devices can reach them. Local auth is required.';
259
+ }
260
+
261
+ function describeWebhookIngress(snapshot: OnboardingSnapshotState): string {
262
+ return hasWebhookOrEventIngress(snapshot)
263
+ ? 'Keep the HTTP listener available for incoming webhooks, callbacks, and automation events.'
264
+ : 'Turn on the HTTP listener for incoming webhooks, callbacks, and automation events.';
265
+ }
266
+
267
+ function describeExternalIntegrations(snapshot: OnboardingSnapshotState): string {
268
+ const integrationCount = new Set<string>([
269
+ ...getExternalIntegrationServiceIds(snapshot),
270
+ ...snapshot.surfaces.configuredEnabledKinds,
271
+ ...snapshot.surfaces.records.filter((surface) => surface.enabled).map((surface) => surface.kind),
272
+ ]).size;
273
+
274
+ if (integrationCount === 0) {
275
+ return 'Show Slack, Discord, Telegram, Teams, Matrix, and other app surfaces so they can be enabled and configured here.';
276
+ }
277
+
278
+ return `Review and configure ${integrationCount} detected external app, service, or surface integration signal(s).`;
279
+ }
280
+
281
+ function getAcknowledgementAccepted(
282
+ snapshot: OnboardingSnapshotState,
283
+ target: OnboardingAcknowledgementTarget,
284
+ ): boolean {
285
+ return snapshot.acknowledgements.accepted[target] === true;
286
+ }
287
+
288
+ function buildNotNeededAcknowledgement(
289
+ snapshot: OnboardingSnapshotState,
290
+ target: OnboardingAcknowledgementTarget,
291
+ detail: string,
292
+ ): OnboardingAcknowledgementState {
293
+ return {
294
+ required: false,
295
+ accepted: getAcknowledgementAccepted(snapshot, target),
296
+ reason: 'not-needed',
297
+ detail,
298
+ };
299
+ }
300
+
301
+ function buildRequiredAcknowledgement(
302
+ snapshot: OnboardingSnapshotState,
303
+ target: OnboardingAcknowledgementTarget,
304
+ reason: Exclude<OnboardingAcknowledgementState['reason'], 'not-needed'>,
305
+ detail: string,
306
+ ): OnboardingAcknowledgementState {
307
+ return {
308
+ required: true,
309
+ accepted: getAcknowledgementAccepted(snapshot, target),
310
+ reason,
311
+ detail,
312
+ };
313
+ }
314
+
315
+ export function deriveStep1Capabilities(
316
+ snapshot: OnboardingSnapshotState,
317
+ ): readonly OnboardingStep1CapabilityItem[] {
318
+ return [
319
+ {
320
+ id: 'local-tui-only',
321
+ label: 'Local TUI Only (No Servers)',
322
+ selected: !hasAnyServerEnabled(snapshot),
323
+ detail: describeLocalTuiOnly(snapshot),
324
+ },
325
+ {
326
+ id: 'browser-access',
327
+ label: 'Open GoodVibes in a Browser',
328
+ selected: hasBrowserAccess(snapshot),
329
+ detail: describeBrowserAccess(snapshot),
330
+ },
331
+ {
332
+ id: 'network-access',
333
+ label: 'Let other devices use GoodVibes',
334
+ selected: hasRemoteDeviceAccess(snapshot),
335
+ detail: describeRemoteDeviceAccess(snapshot),
336
+ },
337
+ {
338
+ id: 'webhook-events',
339
+ label: 'Receive webhooks or events from other tools',
340
+ selected: hasWebhookOrEventIngress(snapshot),
341
+ detail: describeWebhookIngress(snapshot),
342
+ },
343
+ {
344
+ id: 'external-integrations',
345
+ label: 'Connect GoodVibes to external apps and services',
346
+ selected: hasExternalIntegrations(snapshot),
347
+ detail: describeExternalIntegrations(snapshot),
348
+ },
349
+ ];
350
+ }
351
+
352
+ export function deriveStep1CapabilityFlags(
353
+ snapshot: OnboardingSnapshotState,
354
+ ): {
355
+ readonly providers: boolean;
356
+ readonly services: boolean;
357
+ readonly subscriptions: boolean;
358
+ readonly auth: boolean;
359
+ readonly controlPlane: boolean;
360
+ readonly httpListener: boolean;
361
+ readonly web: boolean;
362
+ readonly surfaces: boolean;
363
+ } {
364
+ return {
365
+ providers: hasConfiguredProviderState(snapshot) || hasCustomizedProviderRouting(snapshot),
366
+ services: snapshot.services.total > 0,
367
+ subscriptions: snapshot.subscriptions.active.length > 0 || snapshot.subscriptions.pending.length > 0,
368
+ auth: snapshot.auth.snapshot.userCount > 0
369
+ || snapshot.auth.snapshot.sessionCount > 0
370
+ || snapshot.auth.snapshot.bootstrapCredentialPresent,
371
+ controlPlane: snapshot.bindSettings.daemonEnabled || snapshot.bindSettings.controlPlane.enabled,
372
+ httpListener: snapshot.bindSettings.httpListenerEnabled,
373
+ web: snapshot.bindSettings.web.enabled,
374
+ surfaces: countConfiguredSurfaceKinds(snapshot) > 0,
375
+ };
376
+ }
377
+
378
+ export function deriveStep1_5NetworkMode(
379
+ bindSettings: Pick<OnboardingSnapshotState, 'bindSettings'>['bindSettings'],
380
+ ): OnboardingNetworkMode {
381
+ const activeModes: string[] = [];
382
+ const hasNetworkFacingSurface = bindSettings.httpListenerEnabled || bindSettings.web.enabled;
383
+
384
+ if (
385
+ (bindSettings.daemonEnabled || bindSettings.controlPlane.enabled)
386
+ && (!hasNetworkFacingSurface || bindSettings.controlPlane.hostMode !== 'local')
387
+ ) {
388
+ activeModes.push(bindSettings.controlPlane.hostMode);
389
+ }
390
+
391
+ if (bindSettings.httpListenerEnabled) {
392
+ activeModes.push(bindSettings.httpListener.hostMode);
393
+ }
394
+
395
+ if (bindSettings.web.enabled) {
396
+ activeModes.push(bindSettings.web.hostMode);
397
+ }
398
+
399
+ return activeModes.some((mode) => mode !== 'network') ? 'custom' : 'local-network-default';
400
+ }
401
+
402
+ export function deriveReopenEditAcknowledgementState(
403
+ snapshot: OnboardingSnapshotState,
404
+ ): OnboardingReopenEditAcknowledgementState {
405
+ const providerAccounts = snapshot.providerAccounts?.providers ?? [];
406
+ const providerPendingCount = providerAccounts.filter((provider) => provider.pendingLogin).length;
407
+ const providerConfiguredCount = providerAccounts.filter((provider) => provider.activeRoute !== 'unconfigured' || provider.oauthReady).length;
408
+ const providerRoutingCustomized = hasCustomizedProviderRouting(snapshot);
409
+ const providerSignalCount = getConfiguredProviderSignalIds(snapshot).length;
410
+
411
+ const subscriptionsPendingCount = snapshot.subscriptions.pending.length;
412
+ const subscriptionsActiveCount = snapshot.subscriptions.active.length;
413
+
414
+ const authUserCount = snapshot.auth.snapshot.userCount;
415
+ const authSessionCount = snapshot.auth.snapshot.sessionCount;
416
+ const bootstrapCredentialPresent = snapshot.auth.snapshot.bootstrapCredentialPresent;
417
+
418
+ const providers = providerPendingCount > 0
419
+ ? buildRequiredAcknowledgement(
420
+ snapshot,
421
+ 'providers',
422
+ 'pending-login',
423
+ `${providerPendingCount} provider login(s) are still pending completion.`,
424
+ )
425
+ : providerConfiguredCount > 0 || providerSignalCount > 0
426
+ ? buildRequiredAcknowledgement(
427
+ snapshot,
428
+ 'providers',
429
+ 'configured-routing',
430
+ `${Math.max(providerConfiguredCount, providerSignalCount, 1)} provider auth path(s) are already configured.`,
431
+ )
432
+ : providerRoutingCustomized
433
+ ? buildRequiredAcknowledgement(
434
+ snapshot,
435
+ 'providers',
436
+ 'customized-config',
437
+ 'Provider routing already differs from the default shell configuration.',
438
+ )
439
+ : buildNotNeededAcknowledgement(snapshot, 'providers', 'No existing provider routing needs confirmation.');
440
+
441
+ const subscriptions = subscriptionsPendingCount > 0
442
+ ? buildRequiredAcknowledgement(
443
+ snapshot,
444
+ 'subscriptions',
445
+ 'pending-login',
446
+ `${subscriptionsPendingCount} subscription login(s) are pending completion.`,
447
+ )
448
+ : subscriptionsActiveCount > 0
449
+ ? buildRequiredAcknowledgement(
450
+ snapshot,
451
+ 'subscriptions',
452
+ 'subscription-state',
453
+ `${subscriptionsActiveCount} stored subscription session(s) already exist.`,
454
+ )
455
+ : buildNotNeededAcknowledgement(snapshot, 'subscriptions', 'No stored subscription sessions need confirmation.');
456
+
457
+ const auth = bootstrapCredentialPresent
458
+ ? buildRequiredAcknowledgement(
459
+ snapshot,
460
+ 'auth',
461
+ 'bootstrap-credential',
462
+ 'The local auth bootstrap credential file is still present.',
463
+ )
464
+ : authSessionCount > 0
465
+ ? buildRequiredAcknowledgement(
466
+ snapshot,
467
+ 'auth',
468
+ 'active-sessions',
469
+ `${authSessionCount} local auth session(s) are currently active.`,
470
+ )
471
+ : authUserCount > 0
472
+ ? buildRequiredAcknowledgement(
473
+ snapshot,
474
+ 'auth',
475
+ 'auth-state',
476
+ `${authUserCount} local auth user(s) are already configured.`,
477
+ )
478
+ : buildNotNeededAcknowledgement(snapshot, 'auth', 'No local auth state needs confirmation.');
479
+
480
+ return {
481
+ providers,
482
+ subscriptions,
483
+ auth,
484
+ };
485
+ }
486
+
487
+ export function deriveOnboardingStepState(
488
+ snapshot: OnboardingSnapshotState,
489
+ ): OnboardingStepDerivationState {
490
+ return {
491
+ step1Capabilities: deriveStep1Capabilities(snapshot),
492
+ step1_5NetworkMode: deriveStep1_5NetworkMode(snapshot.bindSettings),
493
+ reopenEditAcknowledgements: deriveReopenEditAcknowledgementState(snapshot),
494
+ };
495
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types.ts';
2
+ export * from './snapshot.ts';
3
+ export * from './derivation.ts';
4
+ export * from './apply.ts';
5
+ export * from './verify.ts';
6
+ export * from './markers.ts';
7
+ export * from './state.ts';
@@ -0,0 +1,161 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { ShellPathService } from '@pellux/goodvibes-sdk/platform/runtime/shell-paths';
4
+ import type {
5
+ OnboardingCompletionMarkerPayload,
6
+ OnboardingCompletionMarkerScope,
7
+ OnboardingCompletionMarkerState,
8
+ OnboardingCompletionMarkersState,
9
+ WriteOnboardingCompletionMarkerOptions,
10
+ } from './types.ts';
11
+
12
+ const ONBOARDING_COMPLETION_MARKER_FILE = 'onboarding-complete.json';
13
+
14
+ type OnboardingShellPaths = Pick<
15
+ ShellPathService,
16
+ 'workingDirectory' | 'resolveProjectPath' | 'resolveUserPath'
17
+ >;
18
+
19
+ function resolveMarkerPath(
20
+ shellPaths: OnboardingShellPaths,
21
+ scope: OnboardingCompletionMarkerScope,
22
+ ): string {
23
+ return scope === 'project'
24
+ ? shellPaths.resolveProjectPath('tui', ONBOARDING_COMPLETION_MARKER_FILE)
25
+ : shellPaths.resolveUserPath('tui', ONBOARDING_COMPLETION_MARKER_FILE);
26
+ }
27
+
28
+ function isObject(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === 'object' && value !== null;
30
+ }
31
+
32
+ function isOnboardingMode(value: unknown): value is OnboardingCompletionMarkerPayload['mode'] {
33
+ return value === 'new' || value === 'edit' || value === 'reopen';
34
+ }
35
+
36
+ function isCompletionMarkerPayload(value: unknown): value is OnboardingCompletionMarkerPayload {
37
+ return isObject(value)
38
+ && value.version === 1
39
+ && typeof value.completedAt === 'number'
40
+ && Number.isFinite(value.completedAt)
41
+ && typeof value.updatedAt === 'number'
42
+ && Number.isFinite(value.updatedAt)
43
+ && typeof value.source === 'string'
44
+ && (value.mode === undefined || isOnboardingMode(value.mode))
45
+ && (value.workspaceRoot === undefined || typeof value.workspaceRoot === 'string');
46
+ }
47
+
48
+ function buildMissingMarkerState(
49
+ scope: OnboardingCompletionMarkerScope,
50
+ path: string,
51
+ ): OnboardingCompletionMarkerState {
52
+ return {
53
+ scope,
54
+ path,
55
+ exists: false,
56
+ payload: null,
57
+ };
58
+ }
59
+
60
+ function buildParseErrorState(
61
+ scope: OnboardingCompletionMarkerScope,
62
+ path: string,
63
+ parseError: string,
64
+ ): OnboardingCompletionMarkerState {
65
+ return {
66
+ scope,
67
+ path,
68
+ exists: true,
69
+ payload: null,
70
+ parseError,
71
+ };
72
+ }
73
+
74
+ function pickEffectiveMarker(
75
+ project: OnboardingCompletionMarkerState,
76
+ user: OnboardingCompletionMarkerState,
77
+ ): OnboardingCompletionMarkerState | null {
78
+ if (project.payload) return project;
79
+ if (user.payload) return user;
80
+ if (project.exists) return project;
81
+ if (user.exists) return user;
82
+ return null;
83
+ }
84
+
85
+ export function getOnboardingCompletionMarkerPath(
86
+ shellPaths: OnboardingShellPaths,
87
+ scope: OnboardingCompletionMarkerScope = 'user',
88
+ ): string {
89
+ return resolveMarkerPath(shellPaths, scope);
90
+ }
91
+
92
+ export function readOnboardingCompletionMarker(
93
+ shellPaths: OnboardingShellPaths,
94
+ scope: OnboardingCompletionMarkerScope = 'user',
95
+ ): OnboardingCompletionMarkerState {
96
+ const path = resolveMarkerPath(shellPaths, scope);
97
+ if (!existsSync(path)) return buildMissingMarkerState(scope, path);
98
+
99
+ try {
100
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
101
+ if (!isCompletionMarkerPayload(parsed)) {
102
+ return buildParseErrorState(scope, path, 'Invalid onboarding completion marker payload.');
103
+ }
104
+
105
+ return {
106
+ scope,
107
+ path,
108
+ exists: true,
109
+ payload: parsed,
110
+ };
111
+ } catch (error) {
112
+ const parseError = error instanceof Error ? error.message : String(error);
113
+ return buildParseErrorState(scope, path, parseError);
114
+ }
115
+ }
116
+
117
+ export function readOnboardingCompletionMarkers(
118
+ shellPaths: OnboardingShellPaths,
119
+ ): OnboardingCompletionMarkersState {
120
+ const user = readOnboardingCompletionMarker(shellPaths, 'user');
121
+ const project = readOnboardingCompletionMarker(shellPaths, 'project');
122
+
123
+ return {
124
+ user,
125
+ project,
126
+ effective: pickEffectiveMarker(project, user),
127
+ };
128
+ }
129
+
130
+ export function writeOnboardingCompletionMarker(
131
+ shellPaths: OnboardingShellPaths,
132
+ options: WriteOnboardingCompletionMarkerOptions = {},
133
+ ): OnboardingCompletionMarkerState {
134
+ const scope = options.scope ?? 'user';
135
+ const path = resolveMarkerPath(shellPaths, scope);
136
+ const completedAt = options.completedAt ?? Date.now();
137
+ const payload: OnboardingCompletionMarkerPayload = {
138
+ version: 1,
139
+ completedAt,
140
+ updatedAt: options.updatedAt ?? completedAt,
141
+ source: options.source ?? 'wizard',
142
+ ...(options.mode ? { mode: options.mode } : {}),
143
+ ...(options.workspaceRoot ?? shellPaths.workingDirectory
144
+ ? { workspaceRoot: options.workspaceRoot ?? shellPaths.workingDirectory }
145
+ : {}),
146
+ };
147
+
148
+ mkdirSync(dirname(path), { recursive: true });
149
+ writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
150
+
151
+ return readOnboardingCompletionMarker(shellPaths, scope);
152
+ }
153
+
154
+ export function clearOnboardingCompletionMarker(
155
+ shellPaths: OnboardingShellPaths,
156
+ scope: OnboardingCompletionMarkerScope = 'user',
157
+ ): OnboardingCompletionMarkerState {
158
+ const path = resolveMarkerPath(shellPaths, scope);
159
+ if (existsSync(path)) unlinkSync(path);
160
+ return buildMissingMarkerState(scope, path);
161
+ }