@pellux/goodvibes-daemon 1.28.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 +383 -0
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/bin/goodvibes-daemon +100 -0
- package/bin/launcher-support.js +226 -0
- package/package.json +96 -0
- package/scripts/check-bun.sh +20 -0
- package/scripts/postinstall.js +244 -0
- package/src/cli/command-catalog.ts +828 -0
- package/src/cli/completion.ts +299 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/parser.ts +55 -0
- package/src/cli/surface-catalog.ts +26 -0
- package/src/cli/types.ts +63 -0
- package/src/cluster/daemon-ws-call.ts +235 -0
- package/src/cluster/raw-reply-route.ts +111 -0
- package/src/config/checkpoint-settings.ts +113 -0
- package/src/config/run-daemon-config-migration.ts +47 -0
- package/src/config/secret-config.ts +175 -0
- package/src/config/secrets.ts +71 -0
- package/src/config/surface.ts +24 -0
- package/src/core/pairing-banner.ts +82 -0
- package/src/daemon/cli.ts +878 -0
- package/src/daemon/config-command.ts +281 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +43 -0
- package/src/daemon/handlers/credentials.ts +139 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/aggregator.ts +375 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
- package/src/daemon/handlers/inbox/index.ts +221 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +239 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
- package/src/daemon/handlers/inbox/providers/email.ts +176 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
- package/src/daemon/handlers/index.ts +117 -0
- package/src/daemon/handlers/register.ts +180 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +120 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +303 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +213 -0
- package/src/daemon/handlers/triage/pipeline.ts +274 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/daemon/lifecycle.ts +41 -0
- package/src/daemon/local-daemon-state.ts +233 -0
- package/src/daemon/pair-command.ts +301 -0
- package/src/daemon/provision-wake-model.ts +81 -0
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +333 -0
- package/src/daemon/send/composition.ts +100 -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/daemon/service-commands.ts +530 -0
- package/src/daemon/sessions-command.ts +209 -0
- package/src/daemon/status-command.ts +481 -0
- package/src/daemon/webui-command.ts +339 -0
- package/src/runtime/boot-tasks.ts +110 -0
- package/src/runtime/cluster-composition.ts +124 -0
- package/src/runtime/cluster-group-composition.ts +284 -0
- package/src/runtime/conversation-rewind-port.ts +171 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +76 -0
- package/src/runtime/device-posture-composition.ts +115 -0
- package/src/runtime/disposal-wiring.ts +101 -0
- package/src/runtime/fleet-needs-input-push.ts +61 -0
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/hosted-session-composition.ts +128 -0
- package/src/runtime/index.ts +100 -0
- package/src/runtime/knowledge-services.ts +101 -0
- package/src/runtime/legacy-daemon-migration.ts +605 -0
- package/src/runtime/legacy-daemon-reconcile.ts +448 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/notification-dispatch.ts +86 -0
- package/src/runtime/plugin-composition.ts +111 -0
- package/src/runtime/runtime-services-types.ts +268 -0
- package/src/runtime/services.ts +756 -0
- package/src/runtime/trigger-services.ts +62 -0
- package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
- package/src/runtime/trust/trust-gated-approvals.ts +169 -0
- package/src/runtime/update-check.ts +61 -0
- package/src/runtime/workspace-checkpointing.ts +116 -0
- package/src/testing/daemon-fixture.ts +276 -0
- package/src/testing/hosted-session-failures.ts +92 -0
- package/src/version.ts +26 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command.ts — `goodvibes-daemon send`, the shell's way to put a message on one
|
|
3
|
+
* of the owner's channels.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Nothing on this machine could send the owner a message from a script. The
|
|
8
|
+
* three binaries exposed no send verb, the daemon's HTTP API answers
|
|
9
|
+
* `401 AUTH_REQUIRED` to the operator token as stored, and driving the agent to
|
|
10
|
+
* do it failed with `Missing Telegram bot token` — because the credential lives
|
|
11
|
+
* in the DAEMON tier (`~/.goodvibes/daemon/settings.json`) and the agent was
|
|
12
|
+
* reading its own surface silo. So the one process that could always send was
|
|
13
|
+
* the daemon, and only while it was running.
|
|
14
|
+
*
|
|
15
|
+
* ## Three properties this command is built around
|
|
16
|
+
*
|
|
17
|
+
* 1. **It uses the delivery path, it is not a second sender.** Everything below
|
|
18
|
+
* ends in `ChannelDeliveryRouter.deliver()` — the same call
|
|
19
|
+
* `AutomationDeliveryManager.sendTarget` makes, reaching the same
|
|
20
|
+
* per-surface strategies in `strategies-core.ts`. Nothing here talks to a
|
|
21
|
+
* provider API directly.
|
|
22
|
+
*
|
|
23
|
+
* 2. **It never exits 0 on a send that did not happen.** The router throws with
|
|
24
|
+
* the provider's own error text and that error is printed and exits non-zero.
|
|
25
|
+
* `AutomationDeliveryManager.deliverText` was the alternative entry point and
|
|
26
|
+
* was NOT used, deliberately: it returns an empty array when a feature gate
|
|
27
|
+
* is off and returns failed attempts rather than throwing, so a caller that
|
|
28
|
+
* did not inspect its result would report success for a message that never
|
|
29
|
+
* left the machine — the exact false-green this command exists to avoid. The
|
|
30
|
+
* gate check it would have done is done here instead, explicitly, and a gate
|
|
31
|
+
* that is off produces a refusal naming the settings key rather than silence.
|
|
32
|
+
*
|
|
33
|
+
* 3. **Every message it sends arrives as literal text.** The body is passed
|
|
34
|
+
* through `inertBodyFor` for the target surface before it reaches the router,
|
|
35
|
+
* and there is no flag, env var or code path that skips that. The message
|
|
36
|
+
* normally comes from the operator's own shell, but the command must not
|
|
37
|
+
* become the way something else's text — a log line, a captured error, a
|
|
38
|
+
* remote agent's output piped in — arrives on the owner's phone rendered as
|
|
39
|
+
* live markup with a clickable link in it. See inert-text.ts.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
43
|
+
import type { ChannelDeliveryRequest } from '@pellux/goodvibes-sdk/platform/channels';
|
|
44
|
+
import { operations } from '@pellux/goodvibes-sdk/platform/runtime';
|
|
45
|
+
const { getMissingSurfaceFeatureFlags, surfaceFeatureGateSettingsKeys } = operations;
|
|
46
|
+
import { findSendChannel, readChannelReadiness, resolveDefaultChannel, SEND_CHANNELS, type SendChannel } from './channels.ts';
|
|
47
|
+
import { describeSendFailure } from './failure-text.ts';
|
|
48
|
+
import { inertBodyFor } from './inert-text.ts';
|
|
49
|
+
|
|
50
|
+
/** What the router needs to actually send; injected so tests never reach the network. */
|
|
51
|
+
export type SendDeliver = (request: ChannelDeliveryRequest) => Promise<string | undefined>;
|
|
52
|
+
|
|
53
|
+
export interface SendCommandDeps {
|
|
54
|
+
/** Built with a `homeDir` so the daemon tier overlays — see channels.ts. */
|
|
55
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
56
|
+
readonly deliver: SendDeliver;
|
|
57
|
+
/** Reads the whole of stdin; only called when no message argument was given. */
|
|
58
|
+
readonly readStdin: () => Promise<string>;
|
|
59
|
+
/** Whether stdin is a terminal. A TTY means there is no piped message to wait for. */
|
|
60
|
+
readonly stdinIsTty: boolean;
|
|
61
|
+
/** Injected so the run id in the delivery request is deterministic under test. */
|
|
62
|
+
readonly newRunId?: (() => string) | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SendCommandResult {
|
|
66
|
+
readonly lines: readonly string[];
|
|
67
|
+
readonly exitCode: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ParsedSendArgs {
|
|
71
|
+
readonly channel: string | null;
|
|
72
|
+
readonly to: string | null;
|
|
73
|
+
readonly title: string | null;
|
|
74
|
+
readonly list: boolean;
|
|
75
|
+
readonly help: boolean;
|
|
76
|
+
readonly words: readonly string[];
|
|
77
|
+
readonly errors: readonly string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const USAGE = [
|
|
81
|
+
'Usage: goodvibes-daemon send [OPTIONS] [MESSAGE...]',
|
|
82
|
+
'',
|
|
83
|
+
'Send a message to one of your configured channels. With no MESSAGE, the',
|
|
84
|
+
'message is read from stdin, so this composes with other tooling.',
|
|
85
|
+
'',
|
|
86
|
+
'Options:',
|
|
87
|
+
' --channel <id> Channel to send to. With none named, your configured',
|
|
88
|
+
' channel is used and the output says which one.',
|
|
89
|
+
' --to <address> Where within that channel: an ntfy topic, a Telegram chat',
|
|
90
|
+
' id, a Slack or Mattermost channel id, a Matrix room id.',
|
|
91
|
+
' Without it the channel\'s configured destination is used.',
|
|
92
|
+
' --title <text> Title for channels that show one (ntfy).',
|
|
93
|
+
' --list Show every channel, whether it is on, and where it sends.',
|
|
94
|
+
' -h, --help Print this help',
|
|
95
|
+
'',
|
|
96
|
+
'A channel that is switched off is refused by name and NOTHING is sent — the',
|
|
97
|
+
'command never quietly falls back to the default, so a message meant for a',
|
|
98
|
+
'quiet channel cannot end up on a noisy one.',
|
|
99
|
+
'',
|
|
100
|
+
'The message is always delivered as literal text: markup a channel would',
|
|
101
|
+
'otherwise render — a Discord masked link, a Slack mention — arrives inert.',
|
|
102
|
+
].join('\n');
|
|
103
|
+
|
|
104
|
+
function parseSendArgs(argv: readonly string[]): ParsedSendArgs {
|
|
105
|
+
let channel: string | null = null;
|
|
106
|
+
let to: string | null = null;
|
|
107
|
+
let title: string | null = null;
|
|
108
|
+
let list = false;
|
|
109
|
+
let help = false;
|
|
110
|
+
const words: string[] = [];
|
|
111
|
+
const errors: string[] = [];
|
|
112
|
+
let optionsEnded = false;
|
|
113
|
+
|
|
114
|
+
const takeValue = (flag: string, value: string | undefined): string | null => {
|
|
115
|
+
if (value === undefined || value.length === 0) {
|
|
116
|
+
errors.push(`${flag} needs a value.`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
123
|
+
const arg = argv[index]!;
|
|
124
|
+
if (optionsEnded) { words.push(arg); continue; }
|
|
125
|
+
// Everything after `--` is message text, so a message that starts with a
|
|
126
|
+
// dash is still sendable.
|
|
127
|
+
if (arg === '--') { optionsEnded = true; continue; }
|
|
128
|
+
if (arg === '-h' || arg === '--help') { help = true; continue; }
|
|
129
|
+
if (arg === '--list') { list = true; continue; }
|
|
130
|
+
if (arg === '--channel' || arg === '-c') { channel = takeValue(arg, argv[++index]); continue; }
|
|
131
|
+
if (arg.startsWith('--channel=')) { channel = takeValue('--channel', arg.slice('--channel='.length)); continue; }
|
|
132
|
+
if (arg === '--to') { to = takeValue(arg, argv[++index]); continue; }
|
|
133
|
+
if (arg.startsWith('--to=')) { to = takeValue('--to', arg.slice('--to='.length)); continue; }
|
|
134
|
+
if (arg === '--title') { title = takeValue(arg, argv[++index]); continue; }
|
|
135
|
+
if (arg.startsWith('--title=')) { title = takeValue('--title', arg.slice('--title='.length)); continue; }
|
|
136
|
+
if (arg.startsWith('-') && arg.length > 1) {
|
|
137
|
+
errors.push(`Unknown option: ${arg}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
words.push(arg);
|
|
141
|
+
}
|
|
142
|
+
return { channel, to, title, list, help, words, errors };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The channels that are actually usable right now, named. Every refusal ends
|
|
147
|
+
* with this: telling someone their channel is not configured without telling
|
|
148
|
+
* them which ones are just moves the guessing to them.
|
|
149
|
+
*/
|
|
150
|
+
function describeConfiguredChannels(config: Pick<ConfigManager, 'get'>): string {
|
|
151
|
+
const usable = readChannelReadiness(config)
|
|
152
|
+
.filter((entry) => entry.enabled && entry.destination !== null)
|
|
153
|
+
.map((entry) => entry.channel.id);
|
|
154
|
+
return usable.length > 0
|
|
155
|
+
? `Configured and ready: ${usable.join(', ')}.`
|
|
156
|
+
: 'No channel is currently both switched on and given a destination — run: goodvibes-daemon send --list';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderChannelList(config: Pick<ConfigManager, 'get'>): string[] {
|
|
160
|
+
const readiness = readChannelReadiness(config);
|
|
161
|
+
const idWidth = Math.max(...SEND_CHANNELS.map((channel) => channel.id.length), 7);
|
|
162
|
+
const lines = ['Channels goodvibes-daemon send can reach:', ''];
|
|
163
|
+
for (const entry of readiness) {
|
|
164
|
+
const state = entry.enabled ? 'on ' : 'off';
|
|
165
|
+
const destination = entry.destination ?? `not set (${entry.channel.destinationKey})`;
|
|
166
|
+
lines.push(` ${entry.channel.id.padEnd(idWidth)} ${state} ${entry.channel.addressLabel}: ${destination}`);
|
|
167
|
+
}
|
|
168
|
+
lines.push('');
|
|
169
|
+
lines.push('Override any channel\'s destination for one message with --to <address>.');
|
|
170
|
+
lines.push('');
|
|
171
|
+
const resolution = resolveDefaultChannel(config);
|
|
172
|
+
if (resolution.kind === 'resolved') {
|
|
173
|
+
lines.push(`Default with no --channel: ${resolution.channel.id} (${resolution.reason}).`);
|
|
174
|
+
} else if (resolution.kind === 'none') {
|
|
175
|
+
lines.push('There is no default: no channel is both switched on and given a destination.');
|
|
176
|
+
} else {
|
|
177
|
+
const names = resolution.candidates.map((entry) => entry.channel.id).join(', ');
|
|
178
|
+
lines.push(`There is no default: ${names} all qualify, so --channel is required.`);
|
|
179
|
+
}
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Refusals that must happen before anything is sent, in the order they matter. */
|
|
184
|
+
function checkChannelUsable(
|
|
185
|
+
config: Pick<ConfigManager, 'get'>,
|
|
186
|
+
channel: SendChannel,
|
|
187
|
+
): readonly string[] {
|
|
188
|
+
if (config.get(channel.enabledKey) !== true) {
|
|
189
|
+
// No fallback to the default channel, deliberately. Falling back is how a
|
|
190
|
+
// message meant for a quiet channel lands on a noisy one, and how a caller
|
|
191
|
+
// that named a channel because it mattered never finds out it was ignored.
|
|
192
|
+
return [
|
|
193
|
+
`${channel.label} is switched off, so nothing was sent.`,
|
|
194
|
+
`Switch it on with: goodvibes surfaces enable ${channel.id} (settings key ${channel.enabledKey})`,
|
|
195
|
+
describeConfiguredChannels(config),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
// The same capability gates the daemon's own delivery honours. Checked here
|
|
199
|
+
// rather than left to AutomationDeliveryManager, which answers a disabled
|
|
200
|
+
// gate with an empty result and no reason.
|
|
201
|
+
const missing = getMissingSurfaceFeatureFlags(config, channel.id);
|
|
202
|
+
if (missing.length > 0) {
|
|
203
|
+
return [
|
|
204
|
+
`${channel.label} is switched on but the capabilities delivery needs are off, so nothing was sent.`,
|
|
205
|
+
`Turn these settings on: ${surfaceFeatureGateSettingsKeys(missing).join(', ')}`,
|
|
206
|
+
describeConfiguredChannels(config),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function runSendCommand(
|
|
213
|
+
argv: readonly string[],
|
|
214
|
+
deps: SendCommandDeps,
|
|
215
|
+
): Promise<SendCommandResult> {
|
|
216
|
+
const parsed = parseSendArgs(argv);
|
|
217
|
+
if (parsed.errors.length > 0) {
|
|
218
|
+
return { lines: [...parsed.errors, '', USAGE], exitCode: 2 };
|
|
219
|
+
}
|
|
220
|
+
if (parsed.help) return { lines: [USAGE], exitCode: 0 };
|
|
221
|
+
if (parsed.list) return { lines: renderChannelList(deps.configManager), exitCode: 0 };
|
|
222
|
+
|
|
223
|
+
// --- which channel -------------------------------------------------------
|
|
224
|
+
let channel: SendChannel;
|
|
225
|
+
let usedDefault = false;
|
|
226
|
+
let defaultReason = '';
|
|
227
|
+
if (parsed.channel !== null) {
|
|
228
|
+
const named = findSendChannel(parsed.channel);
|
|
229
|
+
if (!named) {
|
|
230
|
+
return {
|
|
231
|
+
lines: [
|
|
232
|
+
`Unknown channel: ${parsed.channel}`,
|
|
233
|
+
`Known channels: ${SEND_CHANNELS.map((entry) => entry.id).join(', ')}`,
|
|
234
|
+
describeConfiguredChannels(deps.configManager),
|
|
235
|
+
],
|
|
236
|
+
exitCode: 2,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
channel = named;
|
|
240
|
+
} else {
|
|
241
|
+
const resolution = resolveDefaultChannel(deps.configManager);
|
|
242
|
+
if (resolution.kind === 'none') {
|
|
243
|
+
return {
|
|
244
|
+
lines: [
|
|
245
|
+
'No channel was named and none is configured to be the default, so nothing was sent.',
|
|
246
|
+
'A channel qualifies when it is switched on AND has a destination set.',
|
|
247
|
+
'',
|
|
248
|
+
...renderChannelList(deps.configManager),
|
|
249
|
+
],
|
|
250
|
+
exitCode: 2,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (resolution.kind === 'ambiguous') {
|
|
254
|
+
return {
|
|
255
|
+
lines: [
|
|
256
|
+
`No channel was named and more than one qualifies (${resolution.candidates.map((entry) => entry.channel.id).join(', ')}), so nothing was sent.`,
|
|
257
|
+
'Name one with --channel <id> rather than have this command guess which of your channels to message.',
|
|
258
|
+
],
|
|
259
|
+
exitCode: 2,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
channel = resolution.channel;
|
|
263
|
+
usedDefault = true;
|
|
264
|
+
defaultReason = resolution.reason;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- what to send --------------------------------------------------------
|
|
268
|
+
let message = parsed.words.join(' ');
|
|
269
|
+
if (message.trim().length === 0) {
|
|
270
|
+
if (deps.stdinIsTty) {
|
|
271
|
+
return {
|
|
272
|
+
lines: ['No message given. Pass it as an argument or pipe it on stdin.', '', USAGE],
|
|
273
|
+
exitCode: 2,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
message = (await deps.readStdin()).replace(/\n+$/, '');
|
|
277
|
+
}
|
|
278
|
+
if (message.trim().length === 0) {
|
|
279
|
+
return { lines: ['The message was empty, so nothing was sent.'], exitCode: 2 };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const refusal = checkChannelUsable(deps.configManager, channel);
|
|
283
|
+
if (refusal.length > 0) return { lines: refusal, exitCode: 1 };
|
|
284
|
+
|
|
285
|
+
// --- send ----------------------------------------------------------------
|
|
286
|
+
const runId = deps.newRunId?.() ?? `cli-send-${Date.now().toString(36)}`;
|
|
287
|
+
const title = parsed.title ?? 'GoodVibes';
|
|
288
|
+
const body = inertBodyFor(channel.surfaceKind, message);
|
|
289
|
+
const request: ChannelDeliveryRequest = {
|
|
290
|
+
target: {
|
|
291
|
+
kind: 'surface',
|
|
292
|
+
surfaceKind: channel.surfaceKind,
|
|
293
|
+
...(parsed.to === null ? {} : { address: parsed.to }),
|
|
294
|
+
label: title,
|
|
295
|
+
},
|
|
296
|
+
body,
|
|
297
|
+
title,
|
|
298
|
+
jobId: 'goodvibes-daemon-send',
|
|
299
|
+
runId,
|
|
300
|
+
// A message typed at a shell has no artifacts and no control-plane session
|
|
301
|
+
// to link back to; a link appended here would point at a page the reader
|
|
302
|
+
// did not ask for.
|
|
303
|
+
includeLinks: false,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const preamble = usedDefault
|
|
307
|
+
? [`No channel named — using ${channel.id}: ${defaultReason}.`]
|
|
308
|
+
: [];
|
|
309
|
+
try {
|
|
310
|
+
const responseId = await deps.deliver(request);
|
|
311
|
+
return {
|
|
312
|
+
lines: [
|
|
313
|
+
...preamble,
|
|
314
|
+
`Sent to ${channel.label}${responseId ? ` (${responseId})` : ''}.`,
|
|
315
|
+
],
|
|
316
|
+
exitCode: 0,
|
|
317
|
+
};
|
|
318
|
+
} catch (error) {
|
|
319
|
+
// The provider's own words, not a paraphrase: "Missing Telegram chat id"
|
|
320
|
+
// and "HTTP 401: Unauthorized" are different problems with different fixes,
|
|
321
|
+
// and flattening them into "delivery failed" is what makes a failed send
|
|
322
|
+
// take an hour to diagnose. See failure-text.ts for what is stripped
|
|
323
|
+
// (credentials) and what is deliberately kept (everything else).
|
|
324
|
+
return {
|
|
325
|
+
lines: [
|
|
326
|
+
...preamble,
|
|
327
|
+
`Sending to ${channel.label} failed, so the message did NOT go out.`,
|
|
328
|
+
describeSendFailure(error),
|
|
329
|
+
],
|
|
330
|
+
exitCode: 1,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* composition.ts — the smallest set of services that can put a message on a
|
|
3
|
+
* channel, built from the daemon's own tier.
|
|
4
|
+
*
|
|
5
|
+
* ## Why not `createRuntimeServices`
|
|
6
|
+
*
|
|
7
|
+
* The daemon's full runtime graph starts a LAN scan, a cluster coordinator, an
|
|
8
|
+
* inbox poller, a fleet tick, a memory governor and a config watch. Composing
|
|
9
|
+
* it to send one message would build a second, competing set of that state on a
|
|
10
|
+
* machine that is already running a daemon — the same reason `cluster …` is
|
|
11
|
+
* intercepted before any runtime is constructed (see src/daemon/cli.ts). This
|
|
12
|
+
* builds only the five objects `ChannelDeliveryRouter` needs and starts no
|
|
13
|
+
* timers, binds no sockets and joins no election, so it is safe to run beside a
|
|
14
|
+
* live daemon.
|
|
15
|
+
*
|
|
16
|
+
* ## Why it does not talk to the running daemon over HTTP either
|
|
17
|
+
*
|
|
18
|
+
* That was tried before this command existed: the control-plane API answers
|
|
19
|
+
* `401 AUTH_REQUIRED` to the operator token as stored on disk. More importantly
|
|
20
|
+
* an HTTP-backed send would only work while a daemon is up, and the case this
|
|
21
|
+
* command is for — telling the owner that something has stopped — is exactly
|
|
22
|
+
* when it may not be.
|
|
23
|
+
*
|
|
24
|
+
* ## Where the credentials come from
|
|
25
|
+
*
|
|
26
|
+
* `surfaces.*` is a daemon-owned config prefix, so `ConfigManager` overlays
|
|
27
|
+
* `<home>/.goodvibes/daemon/settings.json` LAST and a bot token stored there is
|
|
28
|
+
* visible here. `SecretsManager` gets the same `daemonHome` the daemon itself
|
|
29
|
+
* resolves, so a `goodvibes://secrets/...` reference in one of those keys
|
|
30
|
+
* resolves against the daemon's store rather than a client silo. Getting that
|
|
31
|
+
* second half wrong is what produced `Missing Telegram bot token` from a
|
|
32
|
+
* machine whose token was present and correct.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ChannelDeliveryRouter } from '@pellux/goodvibes-sdk/platform/channels';
|
|
36
|
+
import { ArtifactStore } from '@pellux/goodvibes-sdk/platform/artifacts';
|
|
37
|
+
import { ConfigManager, ServiceRegistry, SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
38
|
+
import { createShellPathService } from '@/runtime/index.ts';
|
|
39
|
+
import { SecretsManager } from '../../config/secrets.ts';
|
|
40
|
+
import type { SendDeliver } from './command.ts';
|
|
41
|
+
import { GOODVIBES_DAEMON_SURFACE_ROOT } from '../../config/surface.ts';
|
|
42
|
+
|
|
43
|
+
export interface SendStackRoots {
|
|
44
|
+
readonly workingDirectory: string;
|
|
45
|
+
/** The GoodVibes tree root — the directory `.goodvibes/` sits under. */
|
|
46
|
+
readonly homeDirectory: string;
|
|
47
|
+
/** The daemon's own state root, holding the daemon-scoped secret stores. */
|
|
48
|
+
readonly daemonHomeDirectory: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SendStack {
|
|
52
|
+
readonly configManager: ConfigManager;
|
|
53
|
+
readonly deliver: SendDeliver;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build the delivery stack for one CLI send.
|
|
58
|
+
*
|
|
59
|
+
* `secretsManager` is passed to `ChannelDeliveryRouter` because the router
|
|
60
|
+
* REQUIRES it and refuses to construct without it. That requirement is the fix
|
|
61
|
+
* for a real defect: while the parameter was optional, two shipped composition
|
|
62
|
+
* roots (goodvibes-tui and goodvibes-agent) omitted it, still type-checked,
|
|
63
|
+
* still delivered on every surface whose credential happens to sit in config or
|
|
64
|
+
* the environment, and failed only on the surfaces that use a secret reference
|
|
65
|
+
* — at send time, as `Missing Telegram bot token`. This composition root does
|
|
66
|
+
* not repeat that.
|
|
67
|
+
*/
|
|
68
|
+
export function createSendStack(roots: SendStackRoots): SendStack {
|
|
69
|
+
const configManager = new ConfigManager({
|
|
70
|
+
workingDir: roots.workingDirectory,
|
|
71
|
+
homeDir: roots.homeDirectory,
|
|
72
|
+
surfaceRoot: GOODVIBES_DAEMON_SURFACE_ROOT,
|
|
73
|
+
});
|
|
74
|
+
const shellPaths = createShellPathService({
|
|
75
|
+
workingDirectory: roots.workingDirectory,
|
|
76
|
+
homeDirectory: roots.homeDirectory,
|
|
77
|
+
});
|
|
78
|
+
const secretsManager = new SecretsManager({
|
|
79
|
+
projectRoot: roots.workingDirectory,
|
|
80
|
+
globalHome: roots.homeDirectory,
|
|
81
|
+
// Threaded, never defaulted: a daemon told to run out of an isolated tree
|
|
82
|
+
// must not read the real home's credential store, and neither must this.
|
|
83
|
+
daemonHome: roots.daemonHomeDirectory,
|
|
84
|
+
configManager,
|
|
85
|
+
});
|
|
86
|
+
const serviceRegistry = new ServiceRegistry(shellPaths.resolveProjectPath(GOODVIBES_DAEMON_SURFACE_ROOT, 'services.json'), {
|
|
87
|
+
secretsManager,
|
|
88
|
+
subscriptionManager: new SubscriptionManager(shellPaths.resolveUserPath(GOODVIBES_DAEMON_SURFACE_ROOT, 'subscriptions.json')),
|
|
89
|
+
});
|
|
90
|
+
const router = new ChannelDeliveryRouter({
|
|
91
|
+
configManager,
|
|
92
|
+
secretsManager,
|
|
93
|
+
serviceRegistry,
|
|
94
|
+
artifactStore: new ArtifactStore({ configManager }),
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
configManager,
|
|
98
|
+
deliver: (request) => router.deliver(request),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* failure-text.ts — what the operator is told when a send does not happen.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is not `summarizeError`
|
|
5
|
+
*
|
|
6
|
+
* The SDK's `summarizeError` is the right tool nearly everywhere: it classifies
|
|
7
|
+
* an error, adds a hint, and deliberately drops detail so a transcript stays
|
|
8
|
+
* readable. That is the wrong trade here, and measurably so. Given the real
|
|
9
|
+
* Telegram failure
|
|
10
|
+
*
|
|
11
|
+
* Telegram delivery failed HTTP 401: {"ok":false,"description":"Unauthorized"}
|
|
12
|
+
*
|
|
13
|
+
* it returns `Telegram delivery failed HTTP 401` — its `stripJson` pass removes
|
|
14
|
+
* the `{...}`, which is exactly the part naming what went wrong. Given
|
|
15
|
+
* `connect ECONNREFUSED 149.154.167.220:443` it returns "Cannot connect to the
|
|
16
|
+
* provider. Check whether the service is reachable." — advice in place of the
|
|
17
|
+
* address that was refused.
|
|
18
|
+
*
|
|
19
|
+
* A person debugging a message that did not arrive needs the provider's own
|
|
20
|
+
* words: "Unauthorized", "chat not found", and "Bad Request: message is too
|
|
21
|
+
* long" have three different fixes, and all three flatten to the same summary.
|
|
22
|
+
*
|
|
23
|
+
* ## What is removed, and why that is not the same thing
|
|
24
|
+
*
|
|
25
|
+
* Detail is kept; CREDENTIALS are not. This matters more here than in most
|
|
26
|
+
* places because Telegram puts the bot token in the URL PATH
|
|
27
|
+
* (`api.telegram.org/bot<token>/sendMessage`), so a transport error that echoes
|
|
28
|
+
* the request URL would print the owner's bot token to a terminal and into
|
|
29
|
+
* whatever log or pasted report that terminal output ends up in.
|
|
30
|
+
*
|
|
31
|
+
* `redactSensitiveData` from the SDK covers the shapes it knows (bearer tokens,
|
|
32
|
+
* `sk-`/`xoxb-`/`ghp_` keys, home directories). It has NO pattern for a
|
|
33
|
+
* URL-embedded credential, so the two passes below add them. They run BEFORE
|
|
34
|
+
* the SDK pass, because a redacted string must not then be re-scanned in a way
|
|
35
|
+
* that could reveal structure.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { redactSensitiveData } from '@pellux/goodvibes-sdk/platform/utils';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A generous cap. The point of this text is diagnosis, so it is far larger than
|
|
42
|
+
* the 240 characters `summarizeError` allows — but an HTML error page or a
|
|
43
|
+
* multi-megabyte body still must not flood the terminal.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_FAILURE_TEXT = 2_000;
|
|
46
|
+
|
|
47
|
+
const URL_CREDENTIAL_PATTERNS: ReadonlyArray<{ readonly pattern: RegExp; readonly replacement: string }> = [
|
|
48
|
+
// Telegram: the bot token IS the path segment.
|
|
49
|
+
{ pattern: /\/bot\d{5,}:[A-Za-z0-9_-]{10,}/g, replacement: '/bot[REDACTED_BOT_TOKEN]' },
|
|
50
|
+
// Any URL carrying `user:password@host` — BlueBubbles, Mattermost and Matrix
|
|
51
|
+
// base URLs are all operator-supplied and can be written this way.
|
|
52
|
+
{ pattern: /(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s:@]+:[^/\s@]+@/gi, replacement: '$1[REDACTED_CREDENTIALS]@' },
|
|
53
|
+
// A credential passed as a query parameter — BlueBubbles sends `?password=`.
|
|
54
|
+
{
|
|
55
|
+
pattern: /([?&](?:password|token|secret|access_token|api_?key|auth)=)[^&\s"']+/gi,
|
|
56
|
+
replacement: '$1[REDACTED]',
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Strip credentials this command can put on the wire but the SDK pass does not know. */
|
|
61
|
+
export function redactUrlCredentials(text: string): string {
|
|
62
|
+
let result = text;
|
|
63
|
+
for (const { pattern, replacement } of URL_CREDENTIAL_PATTERNS) {
|
|
64
|
+
result = result.replace(pattern, replacement);
|
|
65
|
+
}
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The provider's own failure text, with credentials removed and nothing else
|
|
71
|
+
* rewritten.
|
|
72
|
+
*
|
|
73
|
+
* Never returns an empty string: an error whose message is blank still has to
|
|
74
|
+
* produce something an operator can act on, because the alternative is an exit
|
|
75
|
+
* code with no explanation beside it.
|
|
76
|
+
*/
|
|
77
|
+
export function describeSendFailure(error: unknown): string {
|
|
78
|
+
const raw = error instanceof Error
|
|
79
|
+
? (error.message.trim().length > 0 ? error.message : error.name)
|
|
80
|
+
: typeof error === 'string'
|
|
81
|
+
? error
|
|
82
|
+
: (() => { try { return JSON.stringify(error); } catch { return String(error); } })();
|
|
83
|
+
const message = typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : 'the provider failed without saying why';
|
|
84
|
+
// `cause` is where Node and Bun put the real reason for `fetch failed`, and
|
|
85
|
+
// dropping it is how a genuinely diagnosable DNS or TLS failure becomes two
|
|
86
|
+
// useless words.
|
|
87
|
+
const cause = error instanceof Error && error.cause instanceof Error && error.cause.message.trim().length > 0
|
|
88
|
+
? ` (${error.cause.message.trim()})`
|
|
89
|
+
: '';
|
|
90
|
+
const full = `${message}${cause}`;
|
|
91
|
+
const redacted = redactSensitiveData(redactUrlCredentials(full));
|
|
92
|
+
return redacted.length <= MAX_FAILURE_TEXT ? redacted : `${redacted.slice(0, MAX_FAILURE_TEXT)}…`;
|
|
93
|
+
}
|