@pellux/goodvibes-tui 1.24.2 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/bin/goodvibes-daemon +24 -0
- package/docs/foundation-artifacts/operator-contract.json +18234 -12253
- package/package.json +8 -7
- package/src/cli/help.ts +10 -0
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +149 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- package/src/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +62 -7
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/handlers/index.ts +7 -6
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/input/commands/calendar-runtime.ts +245 -0
- package/src/input/commands/config.ts +68 -2
- package/src/input/commands/connection-status.ts +258 -0
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/mail-runtime.ts +267 -0
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands/review-runtime.ts +13 -1
- package/src/input/commands.ts +11 -0
- package/src/input/composer-editor.ts +4 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-connections.ts +102 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +19 -3
- package/src/input/settings-modal.ts +54 -107
- package/src/main.ts +7 -2
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/settings-modal-connections.ts +63 -0
- package/src/renderer/settings-modal-helpers.ts +6 -0
- package/src/renderer/settings-modal.ts +21 -1
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/context.ts +9 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +0 -4
- package/src/runtime/install-self-check-startup.ts +23 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +243 -0
- package/src/runtime/services.ts +30 -224
- package/src/shell/ui-openers.ts +7 -0
- package/src/verification/verification-ledger.ts +158 -0
- package/src/version.ts +1 -1
- package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
- package/src/daemon/handlers/calendar/ics.ts +0 -556
- package/src/daemon/handlers/calendar/index.ts +0 -316
- package/src/daemon/handlers/email/config.ts +0 -164
- package/src/daemon/handlers/email/imap-connector.ts +0 -441
- package/src/daemon/handlers/email/imap-parsing.ts +0 -499
- package/src/daemon/handlers/email/index.ts +0 -43
- package/src/daemon/handlers/email/read-handlers.ts +0 -80
- package/src/daemon/handlers/email/runtime.ts +0 -140
- package/src/daemon/handlers/email/smtp-connector.ts +0 -557
- package/src/daemon/handlers/email/validation.ts +0 -147
- package/src/daemon/handlers/email/write-handlers.ts +0 -133
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* calendar-runtime.ts — `/calendar`, the terminal surface over the daemon's
|
|
3
|
+
* calendar capability.
|
|
4
|
+
*
|
|
5
|
+
* As with `/mail`, no calendar logic lives here. CalDAV, ICS parsing, settings
|
|
6
|
+
* resolution and the confirmation gate are all in
|
|
7
|
+
* `src/daemon/handlers/calendar/`; this file parses arguments, renders results,
|
|
8
|
+
* and refuses honestly when the capability is not set up.
|
|
9
|
+
*
|
|
10
|
+
* ## What is exposed, and what is not
|
|
11
|
+
*
|
|
12
|
+
* `list` (the agenda) and `get` are exposed: knowing what is next without
|
|
13
|
+
* leaving the terminal is the whole value of a calendar on this surface.
|
|
14
|
+
* `create` is exposed behind the same preview-then-`--confirm` step `/mail
|
|
15
|
+
* send` uses, because the daemon gates it on both `body.confirm` and an
|
|
16
|
+
* explicit user request, and because an event created by a typo is a real
|
|
17
|
+
* nuisance to undo.
|
|
18
|
+
*
|
|
19
|
+
* `calendar.ics.import` and `calendar.ics.export` are deliberately NOT given a
|
|
20
|
+
* command. They are bulk file-level transfers of a whole calendar — an
|
|
21
|
+
* operation whose natural home is a file path and a scheduled job, not a line
|
|
22
|
+
* typed into a coding session. They stay available to any surface through the
|
|
23
|
+
* same catalog; this product simply does not claim to be the right place for
|
|
24
|
+
* them. That is a scoping decision, recorded here so it reads as a choice
|
|
25
|
+
* rather than an omission.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
29
|
+
import {
|
|
30
|
+
EXPLICIT_WRITE_INVOCATION_CONTEXT,
|
|
31
|
+
READ_INVOCATION_CONTEXT,
|
|
32
|
+
errorText,
|
|
33
|
+
probeConnection,
|
|
34
|
+
renderConnectionStatus,
|
|
35
|
+
} from './connection-status.ts';
|
|
36
|
+
|
|
37
|
+
/** Agenda item, exactly the daemon's `calendar.events.list` item shape. */
|
|
38
|
+
interface CalendarEvent {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly title: string;
|
|
41
|
+
readonly start: string;
|
|
42
|
+
readonly end: string;
|
|
43
|
+
readonly location?: string;
|
|
44
|
+
readonly description?: string;
|
|
45
|
+
readonly attendees?: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_LIST_LIMIT = 20;
|
|
49
|
+
const MAX_LIST_LIMIT = 200;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render a timestamp for a terminal agenda.
|
|
53
|
+
*
|
|
54
|
+
* An unparseable value is printed verbatim rather than replaced with a
|
|
55
|
+
* placeholder — showing what the server actually returned is more useful than
|
|
56
|
+
* hiding it behind "invalid date".
|
|
57
|
+
*/
|
|
58
|
+
export function formatWhen(raw: string): string {
|
|
59
|
+
const parsed = new Date(raw);
|
|
60
|
+
if (Number.isNaN(parsed.getTime())) return raw;
|
|
61
|
+
const date = parsed.toISOString().slice(0, 10);
|
|
62
|
+
const time = parsed.toISOString().slice(11, 16);
|
|
63
|
+
return `${date} ${time}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncate(value: string, width: number): string {
|
|
67
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
68
|
+
return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function renderAgenda(events: readonly CalendarEvent[]): string {
|
|
72
|
+
if (events.length === 0) return 'No events in this window.';
|
|
73
|
+
const lines = [`Agenda — ${events.length} event${events.length === 1 ? '' : 's'}:`];
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
const where = event.location ? ` @ ${truncate(event.location, 24)}` : '';
|
|
76
|
+
lines.push(` ${formatWhen(event.start)} ${truncate(event.title, 44).padEnd(44)}${where}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push('', ' /calendar get <id> — open one event');
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function renderEvent(event: CalendarEvent): string {
|
|
83
|
+
const lines = [
|
|
84
|
+
`Title: ${event.title}`,
|
|
85
|
+
`Start: ${formatWhen(event.start)}`,
|
|
86
|
+
`End: ${formatWhen(event.end)}`,
|
|
87
|
+
];
|
|
88
|
+
if (event.location) lines.push(`Where: ${event.location}`);
|
|
89
|
+
const attendees = event.attendees ?? [];
|
|
90
|
+
if (attendees.length > 0) lines.push(`With: ${attendees.join(', ')}`);
|
|
91
|
+
if (event.description && event.description.trim().length > 0) {
|
|
92
|
+
lines.push('', event.description.trimEnd());
|
|
93
|
+
}
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Split `title | start | end` on unescaped pipes — same separator rule as
|
|
99
|
+
* `/mail`, for the same reason: titles contain spaces and per-command quoting
|
|
100
|
+
* conventions are their own bug source.
|
|
101
|
+
*/
|
|
102
|
+
export function parseEventArgs(raw: string): { title: string; start: string; end: string } | null {
|
|
103
|
+
const parts = raw.split('|').map((part) => part.trim());
|
|
104
|
+
if (parts.length !== 3) return null;
|
|
105
|
+
const [title, start, end] = parts;
|
|
106
|
+
if (!title || !start || !end) return null;
|
|
107
|
+
return { title, start, end };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseLimit(raw: string | undefined): number {
|
|
111
|
+
const parsed = Number(raw);
|
|
112
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_LIMIT;
|
|
113
|
+
return Math.min(MAX_LIST_LIMIT, Math.floor(parsed));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const USAGE = [
|
|
117
|
+
'Usage:',
|
|
118
|
+
' /calendar connection status and the agenda ahead',
|
|
119
|
+
' /calendar status connection status only',
|
|
120
|
+
' /calendar list [n] the next n events (default 20)',
|
|
121
|
+
' /calendar get <id> open one event',
|
|
122
|
+
' /calendar create <title | start | end> [--confirm] preview, then create',
|
|
123
|
+
'',
|
|
124
|
+
' Times are ISO-8601, e.g. 2026-08-01T14:00:00Z',
|
|
125
|
+
].join('\n');
|
|
126
|
+
|
|
127
|
+
export function registerCalendarRuntimeCommands(registry: CommandRegistry): void {
|
|
128
|
+
registry.register({
|
|
129
|
+
name: 'calendar',
|
|
130
|
+
aliases: ['cal'],
|
|
131
|
+
description: 'Calendar over the daemon: connection status, agenda, event detail, create',
|
|
132
|
+
usage: '[status|list|get|create] …',
|
|
133
|
+
argsHint: '[status|list <n>|get <id>|create]',
|
|
134
|
+
async handler(args, ctx) {
|
|
135
|
+
const gateway = ctx.workspace.gatewayMethods;
|
|
136
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
137
|
+
|
|
138
|
+
if (sub === 'status') {
|
|
139
|
+
ctx.print(renderConnectionStatus(await probeConnection(gateway, 'calendar')));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!gateway) {
|
|
144
|
+
ctx.print(renderConnectionStatus(await probeConnection(undefined, 'calendar')));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (sub === '' || sub === 'list') {
|
|
149
|
+
await listEvents(ctx, gateway, parseLimit(args[1]), sub === '');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (sub === 'get') {
|
|
153
|
+
await getEvent(ctx, gateway, args[1]);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (sub === 'create') {
|
|
157
|
+
await createEvent(ctx, gateway, args.slice(1));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
ctx.print(USAGE);
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type Gateway = NonNullable<CommandContext['workspace']['gatewayMethods']>;
|
|
166
|
+
|
|
167
|
+
async function withStatusOnFailure(
|
|
168
|
+
ctx: CommandContext,
|
|
169
|
+
gateway: Gateway,
|
|
170
|
+
label: string,
|
|
171
|
+
run: () => Promise<void>,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
await run();
|
|
175
|
+
} catch (error) {
|
|
176
|
+
ctx.print(`[calendar ${label}] ${errorText(error)}`);
|
|
177
|
+
ctx.print(renderConnectionStatus(await probeConnection(gateway, 'calendar')));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function listEvents(ctx: CommandContext, gateway: Gateway, limit: number, withStatus: boolean): Promise<void> {
|
|
182
|
+
await withStatusOnFailure(ctx, gateway, 'list', async () => {
|
|
183
|
+
const result = await gateway.invoke('calendar.events.list', {
|
|
184
|
+
...READ_INVOCATION_CONTEXT,
|
|
185
|
+
body: { limit, from: new Date().toISOString() },
|
|
186
|
+
}) as { readonly events?: readonly CalendarEvent[] };
|
|
187
|
+
if (withStatus) {
|
|
188
|
+
ctx.print(renderConnectionStatus({
|
|
189
|
+
surface: 'calendar',
|
|
190
|
+
state: 'ready',
|
|
191
|
+
detail: 'Calendar is connected — the daemon reached the server and returned a result.',
|
|
192
|
+
nextActions: [],
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
ctx.print(renderAgenda(result.events ?? []));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function getEvent(ctx: CommandContext, gateway: Gateway, eventId: string | undefined): Promise<void> {
|
|
200
|
+
if (!eventId) {
|
|
201
|
+
ctx.print('Usage: /calendar get <id> — the id from /calendar list.');
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
await withStatusOnFailure(ctx, gateway, 'get', async () => {
|
|
205
|
+
const event = await gateway.invoke('calendar.events.get', {
|
|
206
|
+
...READ_INVOCATION_CONTEXT,
|
|
207
|
+
body: { eventId },
|
|
208
|
+
}) as CalendarEvent;
|
|
209
|
+
ctx.print(renderEvent(event));
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function createEvent(ctx: CommandContext, gateway: Gateway, rest: readonly string[]): Promise<void> {
|
|
214
|
+
const raw = rest.filter((part) => part !== '--confirm').join(' ');
|
|
215
|
+
const confirmed = rest.includes('--confirm');
|
|
216
|
+
const parsed = parseEventArgs(raw);
|
|
217
|
+
if (parsed === null) {
|
|
218
|
+
ctx.print([
|
|
219
|
+
'Usage: /calendar create <title | start | end>',
|
|
220
|
+
' Three fields separated by | — for example:',
|
|
221
|
+
' /calendar create Design review | 2026-08-01T14:00:00Z | 2026-08-01T15:00:00Z',
|
|
222
|
+
].join('\n'));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!confirmed) {
|
|
227
|
+
ctx.print([
|
|
228
|
+
'This would create:',
|
|
229
|
+
` Title: ${parsed.title}`,
|
|
230
|
+
` Start: ${formatWhen(parsed.start)}`,
|
|
231
|
+
` End: ${formatWhen(parsed.end)}`,
|
|
232
|
+
'',
|
|
233
|
+
'Nothing has been created. Re-run the same line with --confirm to create it.',
|
|
234
|
+
].join('\n'));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await withStatusOnFailure(ctx, gateway, 'create', async () => {
|
|
239
|
+
const result = await gateway.invoke('calendar.events.create', {
|
|
240
|
+
...EXPLICIT_WRITE_INVOCATION_CONTEXT,
|
|
241
|
+
body: { title: parsed.title, start: parsed.start, end: parsed.end, confirm: true },
|
|
242
|
+
}) as { readonly eventId: string; readonly createdAt: string };
|
|
243
|
+
ctx.print(`Created "${parsed.title}" (event id ${result.eventId}, at ${result.createdAt}).`);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import type { CommandRegistry } from '../command-registry.ts';
|
|
1
|
+
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
2
2
|
import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
3
|
import { setActiveThemeMode, type ThemeMode } from '../../renderer/theme.ts';
|
|
4
|
+
import {
|
|
5
|
+
buildGoodVibesSecretKey,
|
|
6
|
+
defaultSecretBackedScope,
|
|
7
|
+
isSecretConfigKey,
|
|
8
|
+
persistSecretBackedConfigValue,
|
|
9
|
+
} from '../../config/secret-config.ts';
|
|
10
|
+
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
4
11
|
|
|
5
12
|
export function registerConfigCommand(registry: CommandRegistry): void {
|
|
6
13
|
registry.register({
|
|
@@ -9,7 +16,7 @@ export function registerConfigCommand(registry: CommandRegistry): void {
|
|
|
9
16
|
description: 'Open the fullscreen configuration workspace, or set a key directly',
|
|
10
17
|
usage: '[category|key] | set <key> <value>',
|
|
11
18
|
argsHint: '[category|key] | set <key> <value>',
|
|
12
|
-
handler(args, ctx) {
|
|
19
|
+
async handler(args, ctx) {
|
|
13
20
|
// An earlier replay fix: `/config set <key> <value>` used to fall through to
|
|
14
21
|
// the workspace with the assignment silently ignored — the dishonest-
|
|
15
22
|
// fallthrough class. `set` is now a real verb.
|
|
@@ -20,6 +27,17 @@ export function registerConfigCommand(registry: CommandRegistry): void {
|
|
|
20
27
|
ctx.print('Usage: /config set <key> <value> — e.g. /config set display.themeMode light');
|
|
21
28
|
return;
|
|
22
29
|
}
|
|
30
|
+
// A credential key must never take the generic path below: setDynamic
|
|
31
|
+
// writes the value it is handed straight into a settings JSON file, so
|
|
32
|
+
// `/config set surfaces.email.password hunter2` used to put the mailbox
|
|
33
|
+
// password in cleartext on disk. The settings modal already routes these
|
|
34
|
+
// keys through the secret manager (settings-modal-secrets.ts); this
|
|
35
|
+
// command has to route them the same way, or the two ways of setting the
|
|
36
|
+
// same key disagree about where the value lands.
|
|
37
|
+
if (isSecretConfigKey(key)) {
|
|
38
|
+
await handleSecretConfigSet(ctx, key as ConfigKey, value);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
23
41
|
try {
|
|
24
42
|
const before = ctx.platform.configManager.get(key as ConfigKey);
|
|
25
43
|
ctx.platform.configManager.setDynamic(key as ConfigKey, coerceConfigValue(value));
|
|
@@ -49,6 +67,54 @@ export function registerConfigCommand(registry: CommandRegistry): void {
|
|
|
49
67
|
});
|
|
50
68
|
}
|
|
51
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Store a credential-bearing config key through the secret manager instead of
|
|
72
|
+
* writing it into a config file: the value goes to the secret store and the
|
|
73
|
+
* config key keeps only a `goodvibes://secrets/...` reference to it.
|
|
74
|
+
*
|
|
75
|
+
* The entered value is never echoed. The transcript is a diagnostic surface —
|
|
76
|
+
* it is scrolled back through, copied into support threads and captured in
|
|
77
|
+
* screen recordings — so it gets the reference and the store key, never the
|
|
78
|
+
* secret.
|
|
79
|
+
*
|
|
80
|
+
* With no secret manager in this runtime there is nowhere safe to put it, and
|
|
81
|
+
* the command REFUSES rather than falling back to a plaintext config write.
|
|
82
|
+
*/
|
|
83
|
+
async function handleSecretConfigSet(
|
|
84
|
+
ctx: CommandContext,
|
|
85
|
+
key: ConfigKey,
|
|
86
|
+
value: string,
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
const secretKey = buildGoodVibesSecretKey(key);
|
|
89
|
+
const secretsManager = ctx.platform?.secretsManager;
|
|
90
|
+
if (!secretsManager) {
|
|
91
|
+
ctx.print(
|
|
92
|
+
`${key} holds a credential, and this runtime has no secret store to put it in — `
|
|
93
|
+
+ `refusing rather than writing it in cleartext into a config file.\n`
|
|
94
|
+
+ ` Store it with: /secrets set ${secretKey} <value>`,
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const configValue = await persistSecretBackedConfigValue(
|
|
100
|
+
ctx.platform.configManager,
|
|
101
|
+
secretsManager,
|
|
102
|
+
key,
|
|
103
|
+
value,
|
|
104
|
+
);
|
|
105
|
+
const scope = defaultSecretBackedScope(key);
|
|
106
|
+
ctx.print(
|
|
107
|
+
`Set ${key}: stored in the ${scope} secret tier as ${secretKey} (value hidden).\n`
|
|
108
|
+
+ ` ${key} now holds the reference ${configValue} — the value itself is never written to a config file.`
|
|
109
|
+
+ (scope === 'daemon'
|
|
110
|
+
? '\n The daemon reads that tier, so this keeps working with this client closed.'
|
|
111
|
+
: ''),
|
|
112
|
+
);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
ctx.print(`Could not set ${key}: ${summarizeError(e)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
52
118
|
/** Coerce CLI text to the JSON-ish scalar the config schema expects. */
|
|
53
119
|
function coerceConfigValue(raw: string): unknown {
|
|
54
120
|
if (raw === 'true') return true;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connection-status.ts — honest connection state for the mail and calendar
|
|
3
|
+
* capabilities, derived from the daemon's own answer rather than re-decided
|
|
4
|
+
* here.
|
|
5
|
+
*
|
|
6
|
+
* The daemon owns mail (IMAP/SMTP) and calendar (CalDAV/ICS) end to end: the
|
|
7
|
+
* settings resolution, the credential read, the connectors, the confirmation
|
|
8
|
+
* posture (see src/daemon/handlers/email and src/daemon/handlers/calendar).
|
|
9
|
+
* The TUI holds no second copy of any of it. So "is mail connected?" is not a
|
|
10
|
+
* question this file answers from config — it is a question it ASKS, by
|
|
11
|
+
* invoking the cheapest read verb and reading the failure code that comes back:
|
|
12
|
+
*
|
|
13
|
+
* EMAIL_NOT_CONFIGURED host/user are missing
|
|
14
|
+
* EMAIL_CREDENTIALS_MISSING host/user are set, the password secret is not
|
|
15
|
+
* CALENDAR_NOT_CONFIGURED CalDAV url/user are missing
|
|
16
|
+
*
|
|
17
|
+
* Those codes are the daemon's, and the next step each one implies is the
|
|
18
|
+
* daemon's requirement, not a guess here. If the daemon changes what it needs,
|
|
19
|
+
* this surface follows, because it never encoded the rule.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the in-process catalog and not the operator HTTP wire
|
|
22
|
+
*
|
|
23
|
+
* Every `email.*` / `calendar.*` descriptor the SDK catalogs carries
|
|
24
|
+
* `invokable: false`. On the generic HTTP/WS dispatch path that flag is checked
|
|
25
|
+
* by `validateGatewayInvocation` BEFORE any handler lookup and answered with a
|
|
26
|
+
* flat 400 `NOT_INVOKABLE` — so `sdk.operator.invoke('email.inbox.list', …)`
|
|
27
|
+
* cannot reach these verbs no matter what is configured, and a surface built on
|
|
28
|
+
* it would be a dead button.
|
|
29
|
+
*
|
|
30
|
+
* `GatewayMethodCatalog.invoke()` deliberately does NOT consult that flag: a
|
|
31
|
+
* runtime that has registered a real handler is authoritative over whether its
|
|
32
|
+
* own method works. This product registers those handlers into its own catalog
|
|
33
|
+
* at composition time (`createDaemonHandlerComposition`, called by the shared
|
|
34
|
+
* `createRuntimeServices` root that both the TUI and the daemon boot from), so
|
|
35
|
+
* the in-process catalog is a live, correct route to the same code — the same
|
|
36
|
+
* seam `/review` already uses for `checkpoints.*`. That is what these surfaces
|
|
37
|
+
* call.
|
|
38
|
+
*
|
|
39
|
+
* Nothing here reads, holds, or renders a credential. The probe asks for one
|
|
40
|
+
* item and discards it; only presence or absence of a failure reaches the UI.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
44
|
+
|
|
45
|
+
export type ConnectionSurface = 'mail' | 'calendar';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What a surface can be.
|
|
49
|
+
*
|
|
50
|
+
* `checking` exists because the probe is real I/O and the settings workspace
|
|
51
|
+
* renders synchronously — a row has to say something true while the answer is
|
|
52
|
+
* in flight, and "checking" is true where "not configured" would be invented.
|
|
53
|
+
*/
|
|
54
|
+
export type ConnectionState = 'ready' | 'needs-setup' | 'unreachable' | 'checking';
|
|
55
|
+
|
|
56
|
+
export interface ConnectionStatus {
|
|
57
|
+
readonly surface: ConnectionSurface;
|
|
58
|
+
readonly state: ConnectionState;
|
|
59
|
+
/** One line stating what is actually true right now. Never a credential. */
|
|
60
|
+
readonly detail: string;
|
|
61
|
+
/** Concrete next steps. Empty only when the state is `ready`. */
|
|
62
|
+
readonly nextActions: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Display label for a surface. */
|
|
66
|
+
export function connectionSurfaceLabel(surface: ConnectionSurface): string {
|
|
67
|
+
return surface === 'mail' ? 'Mail' : 'Calendar';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The read verb each surface is probed with — the cheapest read it has. */
|
|
71
|
+
export const CONNECTION_PROBE_METHOD = {
|
|
72
|
+
mail: 'email.inbox.list',
|
|
73
|
+
calendar: 'calendar.events.list',
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The setup steps each daemon failure code implies.
|
|
78
|
+
*
|
|
79
|
+
* The `surfaces.*` settings are daemon-owned by prefix (see the SDK's
|
|
80
|
+
* `config-ownership`), so `/config set` on them lands in the daemon's own
|
|
81
|
+
* store and keeps working with this client closed — which is the requirement.
|
|
82
|
+
*
|
|
83
|
+
* The PASSWORDS now have it too, and these steps changed the round the platform
|
|
84
|
+
* gave it to them. A credential is filed in the daemon's tier only when a
|
|
85
|
+
* daemon-owned config path declares it, and the two keys this product's daemon
|
|
86
|
+
* reads — `surfaces.email.password` and `surfaces.calendar.caldavPassword` —
|
|
87
|
+
* were declared in neither `CONFIG_SCHEMA` nor the platform's non-schema
|
|
88
|
+
* daemon-owned path list. `isDaemonOwnedSecretKey()` answered false for both,
|
|
89
|
+
* so a `/secrets set` filed them in THIS client's store where the daemon never
|
|
90
|
+
* looks, and telling anyone to run it would have been handing them a step that
|
|
91
|
+
* silently did nothing. The instruction was the daemon's own environment
|
|
92
|
+
* instead, because that genuinely reached it.
|
|
93
|
+
*
|
|
94
|
+
* The platform release that began serving `email.*` and `calendar.*` declares
|
|
95
|
+
* the whole mail and CalDAV connection as daemon-owned, so both keys answer
|
|
96
|
+
* true now and a write carrying an explicit scope is RELOCATED to the daemon
|
|
97
|
+
* tier rather than filed where it was asked for. `/secrets set` is therefore the
|
|
98
|
+
* step that works, and it is the better one: it needs no restart and no shell
|
|
99
|
+
* access to the machine the daemon runs on. The environment still resolves
|
|
100
|
+
* first, so an operator who already set it there is not broken by this.
|
|
101
|
+
*/
|
|
102
|
+
const DAEMON_TIER_NOTE =
|
|
103
|
+
'The password reaches the DAEMON: a daemon-owned credential is filed in its tier '
|
|
104
|
+
+ 'no matter which surface stores it, so this keeps working with this client closed.';
|
|
105
|
+
|
|
106
|
+
const SETUP_STEPS: Readonly<Record<string, readonly string[]>> = {
|
|
107
|
+
EMAIL_NOT_CONFIGURED: [
|
|
108
|
+
'/config set surfaces.email.host imap.example.com',
|
|
109
|
+
'/config set surfaces.email.user you@example.com',
|
|
110
|
+
`/secrets set GOODVIBES_SURFACES_EMAIL_PASSWORD <password>. ${DAEMON_TIER_NOTE}`,
|
|
111
|
+
],
|
|
112
|
+
EMAIL_CREDENTIALS_MISSING: [
|
|
113
|
+
`/secrets set GOODVIBES_SURFACES_EMAIL_PASSWORD <password>. ${DAEMON_TIER_NOTE}`,
|
|
114
|
+
],
|
|
115
|
+
CALENDAR_NOT_CONFIGURED: [
|
|
116
|
+
'/config set surfaces.calendar.caldavUrl https://example.com/dav/',
|
|
117
|
+
'/config set surfaces.calendar.caldavUser you@example.com',
|
|
118
|
+
`/secrets set GOODVIBES_SURFACES_CALENDAR_CALDAV_PASSWORD <password>. ${DAEMON_TIER_NOTE}`,
|
|
119
|
+
],
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Message text off an unknown thrown value, without assuming Error. */
|
|
123
|
+
export function errorText(error: unknown): string {
|
|
124
|
+
return error instanceof Error ? error.message : String(error);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Pull the daemon's machine code off a thrown handler error.
|
|
129
|
+
*
|
|
130
|
+
* In-process the throw is the daemon's own `HandlerError`, which carries
|
|
131
|
+
* `code` directly. The structured wire body is also checked so the same
|
|
132
|
+
* derivation keeps working if a surface ever reaches these verbs over HTTP.
|
|
133
|
+
*/
|
|
134
|
+
export function daemonErrorCode(error: unknown): string | null {
|
|
135
|
+
if (typeof error !== 'object' || error === null) return null;
|
|
136
|
+
const direct = (error as { readonly code?: unknown }).code;
|
|
137
|
+
if (typeof direct === 'string' && direct.length > 0) return direct;
|
|
138
|
+
const body = (error as { readonly body?: unknown }).body;
|
|
139
|
+
if (typeof body === 'object' && body !== null) {
|
|
140
|
+
const bodyCode = (body as { readonly code?: unknown }).code;
|
|
141
|
+
if (typeof bodyCode === 'string' && bodyCode.length > 0) return bodyCode;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Turn a probe outcome into a status. Pure — the caller does the I/O, so every
|
|
148
|
+
* daemon answer is exercised in tests without a daemon.
|
|
149
|
+
*
|
|
150
|
+
* `error === null` means the read verb returned. That is the only evidence
|
|
151
|
+
* that counts as ready: settings resolved, credential read, server reached.
|
|
152
|
+
*/
|
|
153
|
+
export function describeConnectionProbe(
|
|
154
|
+
surface: ConnectionSurface,
|
|
155
|
+
error: unknown,
|
|
156
|
+
): ConnectionStatus {
|
|
157
|
+
const label = connectionSurfaceLabel(surface);
|
|
158
|
+
|
|
159
|
+
if (error === null) {
|
|
160
|
+
return {
|
|
161
|
+
surface,
|
|
162
|
+
state: 'ready',
|
|
163
|
+
detail: `${label} is connected — the daemon reached the server and returned a result.`,
|
|
164
|
+
nextActions: [],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const code = daemonErrorCode(error);
|
|
169
|
+
const steps = code === null ? undefined : SETUP_STEPS[code];
|
|
170
|
+
if (steps !== undefined) {
|
|
171
|
+
return {
|
|
172
|
+
surface,
|
|
173
|
+
state: 'needs-setup',
|
|
174
|
+
detail: `${label} is not set up yet: ${errorText(error)}`,
|
|
175
|
+
nextActions: steps,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A missing descriptor is a real answer and a different problem from "not
|
|
180
|
+
// configured". Saying "not set up" here would send the owner off to set
|
|
181
|
+
// config keys that would change nothing.
|
|
182
|
+
if (code === 'METHOD_NOT_FOUND' || code === 'NOT_INVOKABLE') {
|
|
183
|
+
return {
|
|
184
|
+
surface,
|
|
185
|
+
state: 'unreachable',
|
|
186
|
+
detail: `This build does not serve ${CONNECTION_PROBE_METHOD[surface]} (${code}).`,
|
|
187
|
+
nextActions: [`Update to a build whose daemon serves the ${surface} methods.`],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
surface,
|
|
193
|
+
state: 'unreachable',
|
|
194
|
+
detail: `${label} could not be checked: ${errorText(error)}`,
|
|
195
|
+
nextActions: ['Check that the runtime composed its daemon handlers: /doctor'],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The status for a surface when the gateway catalog is not wired at all. */
|
|
200
|
+
export function unwiredConnectionStatus(surface: ConnectionSurface): ConnectionStatus {
|
|
201
|
+
return {
|
|
202
|
+
surface,
|
|
203
|
+
state: 'unreachable',
|
|
204
|
+
detail: `${connectionSurfaceLabel(surface)} runs through the gateway method catalog, which is not wired into this session.`,
|
|
205
|
+
nextActions: ['Start the full runtime (this surface is unavailable in a reduced harness).'],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The invocation envelope every read on these surfaces uses. */
|
|
210
|
+
export const READ_INVOCATION_CONTEXT = { context: { clientKind: 'tui' as const } };
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The invocation envelope a confirmation-gated write needs.
|
|
214
|
+
*
|
|
215
|
+
* The daemon's register wrapper demands BOTH `body.confirm === true` and
|
|
216
|
+
* `context.metadata.explicitUserRequest === true`. The second is set only on
|
|
217
|
+
* paths where a person typed the command that performs the write, which is the
|
|
218
|
+
* whole point of the flag — it is never set on a read or on a background
|
|
219
|
+
* refresh.
|
|
220
|
+
*/
|
|
221
|
+
export const EXPLICIT_WRITE_INVOCATION_CONTEXT = {
|
|
222
|
+
context: { clientKind: 'tui' as const, metadata: { explicitUserRequest: true } },
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Probe one surface against the in-process gateway catalog.
|
|
227
|
+
*
|
|
228
|
+
* Asks for a single item — enough to prove the whole path (settings →
|
|
229
|
+
* credential → connector → server) without pulling a mailbox into memory.
|
|
230
|
+
*/
|
|
231
|
+
export async function probeConnection(
|
|
232
|
+
gateway: GatewayMethodCatalog | undefined,
|
|
233
|
+
surface: ConnectionSurface,
|
|
234
|
+
): Promise<ConnectionStatus> {
|
|
235
|
+
if (!gateway) return unwiredConnectionStatus(surface);
|
|
236
|
+
try {
|
|
237
|
+
await gateway.invoke(CONNECTION_PROBE_METHOD[surface], {
|
|
238
|
+
...READ_INVOCATION_CONTEXT,
|
|
239
|
+
body: { limit: 1 },
|
|
240
|
+
});
|
|
241
|
+
return describeConnectionProbe(surface, null);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return describeConnectionProbe(surface, error);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Render one status as transcript lines. Used by `/mail` and `/calendar`. */
|
|
248
|
+
export function renderConnectionStatus(status: ConnectionStatus): string {
|
|
249
|
+
const lines = [
|
|
250
|
+
`${connectionSurfaceLabel(status.surface)}: ${status.state}`,
|
|
251
|
+
` ${status.detail}`,
|
|
252
|
+
];
|
|
253
|
+
if (status.nextActions.length > 0) {
|
|
254
|
+
lines.push(' Next:');
|
|
255
|
+
for (const action of status.nextActions) lines.push(` ${action}`);
|
|
256
|
+
}
|
|
257
|
+
return lines.join('\n');
|
|
258
|
+
}
|
|
@@ -130,7 +130,15 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
|
|
|
130
130
|
}
|
|
131
131
|
void (async () => {
|
|
132
132
|
try {
|
|
133
|
-
|
|
133
|
+
// Daemon scope, stated explicitly here rather than left to the
|
|
134
|
+
// platform's daemon-owned credential registry: that registry
|
|
135
|
+
// recognises credentials by NAME, and this name is derived
|
|
136
|
+
// from an operator-chosen provider name
|
|
137
|
+
// (customProviderKeyName above), so no shipped list can
|
|
138
|
+
// contain it. The daemon is what runs the model, so the key
|
|
139
|
+
// has to reach the daemon tier or a locally-hosted endpoint
|
|
140
|
+
// is usable only while this TUI is open.
|
|
141
|
+
await secretsManager.set(keyName, value, { scope: 'daemon', medium: 'secure' });
|
|
134
142
|
ctx.print(`Stored the API key for '${name}' (value hidden). It resolves live for this provider — no restart needed.`);
|
|
135
143
|
} catch (error) {
|
|
136
144
|
ctx.print(`Could not store the key for '${name}': ${summarizeError(error)}`);
|