@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,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mail-runtime.ts — `/mail`, the terminal surface over the daemon's mail
|
|
3
|
+
* capability.
|
|
4
|
+
*
|
|
5
|
+
* This command implements no mail. Every verb here is one call into the
|
|
6
|
+
* gateway method catalog, where `src/daemon/handlers/email/` owns IMAP, SMTP,
|
|
7
|
+
* settings resolution, the credential read, and the confirmation gate. What
|
|
8
|
+
* lives in this file is argument parsing, rendering, and an honest refusal when
|
|
9
|
+
* the capability is not set up — nothing else.
|
|
10
|
+
*
|
|
11
|
+
* ## What is exposed, and what is not
|
|
12
|
+
*
|
|
13
|
+
* Reading (`list`, `read`) and drafting are exposed because checking mail
|
|
14
|
+
* without leaving the terminal is squarely what this product is for, and
|
|
15
|
+
* because a draft changes nothing outside the account's own Drafts folder.
|
|
16
|
+
*
|
|
17
|
+
* Sending is exposed too — a shipped route with no way to reach it is a route
|
|
18
|
+
* that is not wired up — but never in one step. `/mail send` first renders
|
|
19
|
+
* exactly what would leave the machine and stops; only a re-run carrying
|
|
20
|
+
* `--confirm` sets both flags the daemon's gate demands. That mirrors the
|
|
21
|
+
* preview-then-confirm shape `/review` uses for hunk reverts, and it means the
|
|
22
|
+
* dangerous action cannot happen from a typo or a recalled line of history.
|
|
23
|
+
*
|
|
24
|
+
* Message bodies are attacker-controlled text. They are printed as transcript
|
|
25
|
+
* output, which is display-only and never enters the model's message history,
|
|
26
|
+
* so a body cannot smuggle instructions into a turn by being read here.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
30
|
+
import {
|
|
31
|
+
EXPLICIT_WRITE_INVOCATION_CONTEXT,
|
|
32
|
+
READ_INVOCATION_CONTEXT,
|
|
33
|
+
errorText,
|
|
34
|
+
probeConnection,
|
|
35
|
+
renderConnectionStatus,
|
|
36
|
+
} from './connection-status.ts';
|
|
37
|
+
|
|
38
|
+
/** Inbox summary, exactly the daemon's `email.inbox.list` item shape. */
|
|
39
|
+
interface InboxMessage {
|
|
40
|
+
readonly uid: number;
|
|
41
|
+
readonly from: string;
|
|
42
|
+
readonly subject: string;
|
|
43
|
+
readonly date: string;
|
|
44
|
+
readonly unread: boolean;
|
|
45
|
+
readonly bodyPreview: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Full message, exactly the daemon's `email.inbox.read` output shape. */
|
|
49
|
+
interface FullMessage {
|
|
50
|
+
readonly uid: number;
|
|
51
|
+
readonly from: string;
|
|
52
|
+
readonly subject: string;
|
|
53
|
+
readonly date: string;
|
|
54
|
+
readonly bodyText?: string;
|
|
55
|
+
readonly attachments?: ReadonlyArray<{ readonly filename: string; readonly sizeBytes: number }>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_LIST_LIMIT = 15;
|
|
59
|
+
const MAX_LIST_LIMIT = 100;
|
|
60
|
+
|
|
61
|
+
function truncate(value: string, width: number): string {
|
|
62
|
+
const flat = value.replace(/\s+/g, ' ').trim();
|
|
63
|
+
return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderInboxList(messages: readonly InboxMessage[]): string {
|
|
67
|
+
if (messages.length === 0) return 'Inbox is empty (nothing matched).';
|
|
68
|
+
const lines = [`Inbox — ${messages.length} message${messages.length === 1 ? '' : 's'}:`];
|
|
69
|
+
for (const message of messages) {
|
|
70
|
+
const mark = message.unread ? '●' : ' ';
|
|
71
|
+
lines.push(` ${mark} ${String(message.uid).padStart(6)} ${truncate(message.from, 28).padEnd(28)} ${truncate(message.subject, 48)}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push('', ' /mail read <uid> — open one message');
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderMessage(message: FullMessage): string {
|
|
78
|
+
const lines = [
|
|
79
|
+
`From: ${message.from}`,
|
|
80
|
+
`Subject: ${message.subject}`,
|
|
81
|
+
`Date: ${message.date}`,
|
|
82
|
+
];
|
|
83
|
+
const attachments = message.attachments ?? [];
|
|
84
|
+
if (attachments.length > 0) {
|
|
85
|
+
lines.push(`Files: ${attachments.map((a) => `${a.filename} (${a.sizeBytes} bytes)`).join(', ')}`);
|
|
86
|
+
}
|
|
87
|
+
lines.push('', message.bodyText && message.bodyText.trim().length > 0
|
|
88
|
+
? message.bodyText.trimEnd()
|
|
89
|
+
: '(no plain-text body)');
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split `to | subject | body` on unescaped pipes.
|
|
95
|
+
*
|
|
96
|
+
* A pipe rather than positional words because a subject and a body both
|
|
97
|
+
* contain spaces, and quoting rules invented per-command are their own bug
|
|
98
|
+
* source. Exactly three fields are required, so a missing one is a clear
|
|
99
|
+
* usage error rather than a message sent with an empty subject.
|
|
100
|
+
*/
|
|
101
|
+
export function parseComposeArgs(raw: string): { to: string; subject: string; body: string } | null {
|
|
102
|
+
const parts = raw.split('|').map((part) => part.trim());
|
|
103
|
+
if (parts.length !== 3) return null;
|
|
104
|
+
const [to, subject, body] = parts;
|
|
105
|
+
if (!to || !subject || !body) return null;
|
|
106
|
+
return { to, subject, body };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseLimit(raw: string | undefined): number {
|
|
110
|
+
const parsed = Number(raw);
|
|
111
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_LIMIT;
|
|
112
|
+
return Math.min(MAX_LIST_LIMIT, Math.floor(parsed));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const USAGE = [
|
|
116
|
+
'Usage:',
|
|
117
|
+
' /mail connection status and the latest messages',
|
|
118
|
+
' /mail status connection status only',
|
|
119
|
+
' /mail list [n] list the n most recent messages (default 15)',
|
|
120
|
+
' /mail read <uid> open one message',
|
|
121
|
+
' /mail draft <to | subject | body> save a draft to the account',
|
|
122
|
+
' /mail send <to | subject | body> [--confirm] preview, then send',
|
|
123
|
+
].join('\n');
|
|
124
|
+
|
|
125
|
+
export function registerMailRuntimeCommands(registry: CommandRegistry): void {
|
|
126
|
+
registry.register({
|
|
127
|
+
name: 'mail',
|
|
128
|
+
aliases: ['email'],
|
|
129
|
+
description: 'Mail over the daemon: connection status, inbox, read, draft, send',
|
|
130
|
+
usage: '[status|list|read|draft|send] …',
|
|
131
|
+
argsHint: '[status|list <n>|read <uid>|draft|send]',
|
|
132
|
+
async handler(args, ctx) {
|
|
133
|
+
const gateway = ctx.workspace.gatewayMethods;
|
|
134
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
135
|
+
|
|
136
|
+
if (sub === 'status') {
|
|
137
|
+
ctx.print(renderConnectionStatus(await probeConnection(gateway, 'mail')));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!gateway) {
|
|
142
|
+
ctx.print(renderConnectionStatus(await probeConnection(undefined, 'mail')));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (sub === '' || sub === 'list') {
|
|
147
|
+
await listInbox(ctx, gateway, parseLimit(args[1]), sub === '');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (sub === 'read') {
|
|
151
|
+
await readMessage(ctx, gateway, args[1]);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (sub === 'draft' || sub === 'send') {
|
|
155
|
+
await compose(ctx, gateway, sub, args.slice(1));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
ctx.print(USAGE);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
type Gateway = NonNullable<CommandContext['workspace']['gatewayMethods']>;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Every failure path renders the connection status rather than a bare error:
|
|
167
|
+
* "not configured" is far more often the real cause than a transient fault,
|
|
168
|
+
* and the status carries the exact next step.
|
|
169
|
+
*/
|
|
170
|
+
async function withStatusOnFailure(
|
|
171
|
+
ctx: CommandContext,
|
|
172
|
+
gateway: Gateway,
|
|
173
|
+
label: string,
|
|
174
|
+
run: () => Promise<void>,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
try {
|
|
177
|
+
await run();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
ctx.print(`[mail ${label}] ${errorText(error)}`);
|
|
180
|
+
ctx.print(renderConnectionStatus(await probeConnection(gateway, 'mail')));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function listInbox(ctx: CommandContext, gateway: Gateway, limit: number, withStatus: boolean): Promise<void> {
|
|
185
|
+
await withStatusOnFailure(ctx, gateway, 'list', async () => {
|
|
186
|
+
const result = await gateway.invoke('email.inbox.list', {
|
|
187
|
+
...READ_INVOCATION_CONTEXT,
|
|
188
|
+
body: { limit },
|
|
189
|
+
}) as { readonly messages?: readonly InboxMessage[] };
|
|
190
|
+
if (withStatus) {
|
|
191
|
+
ctx.print(renderConnectionStatus({
|
|
192
|
+
surface: 'mail',
|
|
193
|
+
state: 'ready',
|
|
194
|
+
detail: 'Mail is connected — the daemon reached the server and returned a result.',
|
|
195
|
+
nextActions: [],
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
ctx.print(renderInboxList(result.messages ?? []));
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function readMessage(ctx: CommandContext, gateway: Gateway, rawUid: string | undefined): Promise<void> {
|
|
203
|
+
const uid = Number(rawUid);
|
|
204
|
+
if (!Number.isFinite(uid) || uid <= 0) {
|
|
205
|
+
ctx.print('Usage: /mail read <uid> — the uid column from /mail list.');
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await withStatusOnFailure(ctx, gateway, 'read', async () => {
|
|
209
|
+
const message = await gateway.invoke('email.inbox.read', {
|
|
210
|
+
...READ_INVOCATION_CONTEXT,
|
|
211
|
+
body: { uid },
|
|
212
|
+
}) as FullMessage;
|
|
213
|
+
ctx.print(renderMessage(message));
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function compose(ctx: CommandContext, gateway: Gateway, verb: 'draft' | 'send', rest: readonly string[]): Promise<void> {
|
|
218
|
+
const raw = rest.filter((part) => part !== '--confirm').join(' ');
|
|
219
|
+
const confirmed = rest.includes('--confirm');
|
|
220
|
+
const parsed = parseComposeArgs(raw);
|
|
221
|
+
if (parsed === null) {
|
|
222
|
+
ctx.print([
|
|
223
|
+
`Usage: /mail ${verb} <to | subject | body>`,
|
|
224
|
+
' Three fields separated by | — for example:',
|
|
225
|
+
` /mail ${verb} me@example.com | Build is green | All gates passed.`,
|
|
226
|
+
].join('\n'));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (verb === 'draft') {
|
|
231
|
+
await withStatusOnFailure(ctx, gateway, 'draft', async () => {
|
|
232
|
+
// No `confirm` in the body: the SDK contract for draft.create does not
|
|
233
|
+
// declare one (its input schema is closed), and the handler is registered
|
|
234
|
+
// without the confirmation wrapper. Sending one would be a field the
|
|
235
|
+
// schema rejects.
|
|
236
|
+
const result = await gateway.invoke('email.draft.create', {
|
|
237
|
+
...EXPLICIT_WRITE_INVOCATION_CONTEXT,
|
|
238
|
+
body: { to: parsed.to, subject: parsed.subject, body: parsed.body },
|
|
239
|
+
}) as { readonly uid: number; readonly draftId: string };
|
|
240
|
+
ctx.print(`Draft saved to the account (uid ${result.uid}). Nothing was sent.`);
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Send: preview first, always. The daemon would refuse an unconfirmed call
|
|
246
|
+
// anyway; showing exactly what would leave the machine is the point.
|
|
247
|
+
if (!confirmed) {
|
|
248
|
+
ctx.print([
|
|
249
|
+
'This would send:',
|
|
250
|
+
` To: ${parsed.to}`,
|
|
251
|
+
` Subject: ${parsed.subject}`,
|
|
252
|
+
'',
|
|
253
|
+
parsed.body,
|
|
254
|
+
'',
|
|
255
|
+
'Nothing has been sent. Re-run the same line with --confirm to send it.',
|
|
256
|
+
].join('\n'));
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
await withStatusOnFailure(ctx, gateway, 'send', async () => {
|
|
261
|
+
const result = await gateway.invoke('email.send', {
|
|
262
|
+
...EXPLICIT_WRITE_INVOCATION_CONTEXT,
|
|
263
|
+
body: { to: parsed.to, subject: parsed.subject, body: parsed.body, confirm: true },
|
|
264
|
+
}) as { readonly messageId: string; readonly sentAt: string };
|
|
265
|
+
ctx.print(`Sent to ${parsed.to} at ${result.sentAt} (message id ${result.messageId}).`);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -65,7 +65,12 @@ export function registerOperatorRuntimeCommands(registry: CommandRegistry): void
|
|
|
65
65
|
|
|
66
66
|
registry.register({
|
|
67
67
|
name: 'profiles',
|
|
68
|
-
|
|
68
|
+
// The singular `profile` alias was given up to /profile, the owner profile
|
|
69
|
+
// (docs/owner-profile.md). These are two unrelated things — this command
|
|
70
|
+
// loads saved display/provider settings presets, and the other one is what
|
|
71
|
+
// the platform knows about the person who owns it — and the singular word
|
|
72
|
+
// now belongs to the second. `/profiles` is unchanged; only the abbreviation
|
|
73
|
+
// moved. See the naming note in docs/owner-profile.md §3.
|
|
69
74
|
description: 'Browse and load config profiles',
|
|
70
75
|
handler(_args, ctx) {
|
|
71
76
|
if (ctx.openProfilePicker) {
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /payments card — capture a payment card through the composer's concealed
|
|
3
|
+
* input mode, following the same guided-chain pattern as /channel pair
|
|
4
|
+
* (channel-pairing.ts) and the SAME concealed-input mechanism as
|
|
5
|
+
* provider-key-intake.ts (see concealed-input.ts).
|
|
6
|
+
*
|
|
7
|
+
* Card number, expiry, CVV, and cardholder name are secret-tier (see
|
|
8
|
+
* config/secret-config.ts's SECRET_CONFIG_KEYS): each is entered through one
|
|
9
|
+
* masked concealed-input prompt in turn and stored via
|
|
10
|
+
* persistSecretBackedConfigValue, the same secret-manager + config-reference
|
|
11
|
+
* path every other secret-backed setting in this app uses. The plaintext
|
|
12
|
+
* never reaches the transcript, input history, or a log line — only a
|
|
13
|
+
* redacted confirmation is printed after each field.
|
|
14
|
+
*
|
|
15
|
+
* Billing and shipping address are ordinary (non-secret) config fields, now
|
|
16
|
+
* real structured CONFIG_SCHEMA entries (payments.billingAddress.{name,
|
|
17
|
+
* line1, line2, city, region, postalCode, country} and the shippingAddress
|
|
18
|
+
* equivalents) — this command points the user at the Payments settings
|
|
19
|
+
* category (or /config payments) to set them, the same way /channel pair
|
|
20
|
+
* directs non-secret credential fields, rather than routing plain text
|
|
21
|
+
* through the concealed (masked) input path where it does not belong.
|
|
22
|
+
*
|
|
23
|
+
* Stored with `scope: 'daemon'`: the daemon — not just this interactive
|
|
24
|
+
* client — is what completes an autonomous purchase, so the credential must
|
|
25
|
+
* land in the daemon-scoped secret tier the daemon actually reads.
|
|
26
|
+
*
|
|
27
|
+
* The config-reference half (the `goodvibes://secrets/...` marker
|
|
28
|
+
* persistSecretBackedConfigValue writes alongside the secret) goes through
|
|
29
|
+
* `ctx.platform.configManager` directly — the same real ConfigManager every
|
|
30
|
+
* other command in this app uses, not a TUI-local store. See
|
|
31
|
+
* payments-config.ts's header comment for why these particular keys
|
|
32
|
+
* (`payments.cardNumber`, etc., flat sub-keys under the SDK's real
|
|
33
|
+
* `payments` section) are ConfigManager-safe even though the SDK's
|
|
34
|
+
* CONFIG_SCHEMA has no scalar entry for them yet: `payments.` is a
|
|
35
|
+
* daemon-owned config prefix, so the reference lands in the daemon's own
|
|
36
|
+
* settings file and is visible to the daemon, the webui and the agent —
|
|
37
|
+
* not just this TUI session.
|
|
38
|
+
*
|
|
39
|
+
* ── Card details are entered only at a local terminal or the webui ────────
|
|
40
|
+
*
|
|
41
|
+
* Owner ruling (see the SDK's platform/payments/entry-surface.ts): card
|
|
42
|
+
* material may be TYPED only on `tui`, `agent-terminal`, or `webui` — never
|
|
43
|
+
* over Telegram, ntfy, Discord, Slack, WhatsApp, Signal, a webhook, or any
|
|
44
|
+
* other remote messaging surface. A card number typed into a hosted chat is
|
|
45
|
+
* stored on that provider's own servers, in history this app cannot erase,
|
|
46
|
+
* and it already passed through their infrastructure before reaching us —
|
|
47
|
+
* our own encryption at rest does nothing for a value copied elsewhere on
|
|
48
|
+
* its way in. This is a SEPARATE question from which surfaces may APPROVE or
|
|
49
|
+
* VETO a purchase (every command-authority channel still can); the two are
|
|
50
|
+
* never merged into one check.
|
|
51
|
+
*
|
|
52
|
+
* This command always runs inside the TUI's own composer (CARD_ENTRY_SURFACE
|
|
53
|
+
* below), which the SDK's allowlist accepts — so this gate changes nothing
|
|
54
|
+
* about today's behavior. What it buys is that the refusal already exists
|
|
55
|
+
* the moment this app (or a shared command registry) ever grows a path that
|
|
56
|
+
* lets `/payments card` be reached from somewhere else: the check is real
|
|
57
|
+
* and SDK-driven, not an implicit "this is the TUI so it's fine" assumption.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import type { CommandRegistry, CommandContext } from '../command-registry.ts';
|
|
61
|
+
import { persistSecretBackedConfigValue } from '../../config/secret-config.ts';
|
|
62
|
+
import type { ConfigKey } from '../../config/index.ts';
|
|
63
|
+
import { describeCardEntryRefusal, mayOfferCardEntryFlow } from '@pellux/goodvibes-sdk/platform/payments';
|
|
64
|
+
import {
|
|
65
|
+
PAYMENTS_CARD_NUMBER_CONFIG_KEY,
|
|
66
|
+
PAYMENTS_CARD_EXPIRY_CONFIG_KEY,
|
|
67
|
+
PAYMENTS_CARD_CVV_CONFIG_KEY,
|
|
68
|
+
PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY,
|
|
69
|
+
} from '../payments-config.ts';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* This command is wired only into the TUI's own composer command registry —
|
|
73
|
+
* it is never reachable from a channel bridge (Telegram, Discord, ...), each
|
|
74
|
+
* of which has its own separate command surface in the daemon. The constant
|
|
75
|
+
* still names the surface explicitly and is checked through the SDK's own
|
|
76
|
+
* allowlist below, rather than skipping the check because "this is the TUI".
|
|
77
|
+
*/
|
|
78
|
+
export const CARD_ENTRY_SURFACE = 'tui';
|
|
79
|
+
|
|
80
|
+
interface CardField {
|
|
81
|
+
readonly key: ConfigKey;
|
|
82
|
+
readonly label: string;
|
|
83
|
+
readonly placeholder: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Order matters only for the guided prompt sequence, not for storage. */
|
|
87
|
+
const CARD_SECRET_FIELDS: readonly CardField[] = [
|
|
88
|
+
{ key: PAYMENTS_CARD_NUMBER_CONFIG_KEY, label: 'Card number', placeholder: '4242424242424242' },
|
|
89
|
+
{ key: PAYMENTS_CARD_EXPIRY_CONFIG_KEY, label: 'Expiry (MM/YY)', placeholder: '12/34' },
|
|
90
|
+
{ key: PAYMENTS_CARD_CVV_CONFIG_KEY, label: 'CVV', placeholder: '123' },
|
|
91
|
+
{ key: PAYMENTS_CARD_CARDHOLDER_NAME_CONFIG_KEY, label: 'Cardholder name', placeholder: 'as printed on the card' },
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
function fieldConfigured(ctx: CommandContext, field: CardField): boolean {
|
|
95
|
+
const raw: unknown = ctx.platform.configManager.get(field.key);
|
|
96
|
+
return typeof raw === 'string' && raw.trim().length > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderCardStatus(ctx: CommandContext): string[] {
|
|
100
|
+
const lines = ['Payment card on file:'];
|
|
101
|
+
for (const field of CARD_SECRET_FIELDS) {
|
|
102
|
+
lines.push(` ${field.label.padEnd(20)} ${fieldConfigured(ctx, field) ? 'set' : 'not set'}`);
|
|
103
|
+
}
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push('Run /payments card to enter or replace the card (masked input, chained prompts).');
|
|
106
|
+
lines.push('Billing/shipping address, budgets, windows and the rest of the payment capability are ordinary config values: set them via /config payments or the Settings > Payments category.');
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Chain a masked prompt for each card secret field, storing each through the daemon-scoped secret tier. */
|
|
111
|
+
function promptCardFields(ctx: CommandContext, fields: readonly CardField[], index: number): void {
|
|
112
|
+
if (index >= fields.length) {
|
|
113
|
+
ctx.print('\nCard stored. Billing/shipping address and the rest of the payment capability are ordinary config values — set them via /config payments or the Settings > Payments category.');
|
|
114
|
+
ctx.renderRequest();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const field = fields[index]!;
|
|
118
|
+
if (!ctx.beginConcealedInput) {
|
|
119
|
+
ctx.print('[payments] Concealed input is unavailable on this surface; card entry requires it and cannot fall back to plaintext.');
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
ctx.print(`[payments] Enter ${field.label} (e.g. ${field.placeholder}) — masked; Enter to store, Esc to stop.`);
|
|
123
|
+
ctx.beginConcealedInput({
|
|
124
|
+
label: field.label,
|
|
125
|
+
onSubmit: (value) => {
|
|
126
|
+
if (value.length === 0) {
|
|
127
|
+
ctx.print(`[payments] ${field.label} left unset.`);
|
|
128
|
+
promptCardFields(ctx, fields, index + 1);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
void persistSecretBackedConfigValue(
|
|
132
|
+
ctx.platform.configManager,
|
|
133
|
+
ctx.platform.secretsManager,
|
|
134
|
+
field.key,
|
|
135
|
+
value,
|
|
136
|
+
{ scope: 'daemon' },
|
|
137
|
+
)
|
|
138
|
+
.then(() => {
|
|
139
|
+
ctx.print(`[payments] ${field.label} stored securely (hidden).`);
|
|
140
|
+
promptCardFields(ctx, fields, index + 1);
|
|
141
|
+
})
|
|
142
|
+
.catch((error: unknown) => {
|
|
143
|
+
ctx.print(`[payments] Failed to store ${field.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
promptCardFields(ctx, fields, index + 1);
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
onCancel: () => {
|
|
148
|
+
ctx.print(`[payments] Stopped. Re-run /payments card to finish the remaining fields.`);
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start the card-entry flow, gated on the SDK's own entry-surface allowlist
|
|
155
|
+
* rather than an assumption baked into this command. `surface` defaults to
|
|
156
|
+
* this command's real, fixed identity (CARD_ENTRY_SURFACE) — exposed as a
|
|
157
|
+
* parameter only so tests can drive the refusal path without needing this
|
|
158
|
+
* app to actually be reachable from a remote channel.
|
|
159
|
+
*/
|
|
160
|
+
export function startCardEntryFlow(ctx: CommandContext, surface: string = CARD_ENTRY_SURFACE): void {
|
|
161
|
+
if (!mayOfferCardEntryFlow(surface)) {
|
|
162
|
+
ctx.print(describeCardEntryRefusal(surface));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!ctx.beginConcealedInput) {
|
|
166
|
+
ctx.print('[payments] Concealed input is unavailable on this surface.');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
ctx.print(`Entering ${CARD_SECRET_FIELDS.length} card field(s) — masked; Esc to stop at any point.`);
|
|
170
|
+
promptCardFields(ctx, CARD_SECRET_FIELDS, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Entry point for `/payments [card|status]` — exported directly so tests can drive it without the registry. */
|
|
174
|
+
export function runPaymentsCommand(args: readonly string[], ctx: CommandContext): void {
|
|
175
|
+
const sub = (args[0] ?? '').toLowerCase();
|
|
176
|
+
if (sub === '' || sub === 'status') {
|
|
177
|
+
ctx.print(renderCardStatus(ctx).join('\n'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (sub === 'card') {
|
|
181
|
+
startCardEntryFlow(ctx);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
ctx.print('Usage: /payments [card|status]');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Card secret fields in prompt order — exported for tests that need to drive the chain field-by-field. */
|
|
188
|
+
export { CARD_SECRET_FIELDS };
|
|
189
|
+
|
|
190
|
+
export function registerPaymentCardCommands(registry: CommandRegistry): void {
|
|
191
|
+
registry.register({
|
|
192
|
+
name: 'payments',
|
|
193
|
+
description: 'Payment card on file for daemon-initiated purchases (card entry is masked input)',
|
|
194
|
+
usage: '/payments [card]',
|
|
195
|
+
argsHint: '[card]',
|
|
196
|
+
handler(args, ctx) {
|
|
197
|
+
runPaymentsCommand(args, ctx);
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|