@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,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pair-command.ts — `goodvibes-daemon pair`.
|
|
3
|
+
*
|
|
4
|
+
* Two forms.
|
|
5
|
+
*
|
|
6
|
+
* LOCAL FORM — `pair` with no `--host`, or one naming this machine — reprints
|
|
7
|
+
* the pairing block a daemon prints once as it starts: the web origin, the
|
|
8
|
+
* offers a new device can accept, what it will be able to do, and a QR code
|
|
9
|
+
* encoding the deep link that opens the web app already signed in. It reads
|
|
10
|
+
* from THIS machine's own token store (the shared companion token in
|
|
11
|
+
* `<daemon home>/operator-tokens.json` plus the web origin its settings
|
|
12
|
+
* resolve to) and reprints the EXISTING token rather than minting a new one,
|
|
13
|
+
* so a link printed here and the one printed at boot are the same link.
|
|
14
|
+
*
|
|
15
|
+
* REMOTE FORM — `pair --host <name> [--port] [--token]` — asks THAT daemon to
|
|
16
|
+
* MINT A NEW per-device pairing token over `pairing.handoff.create` and prints
|
|
17
|
+
* the pairing block for it. Minting is a different act than reprinting: it is
|
|
18
|
+
* a fresh token, and every token that daemon already issued (its own shared
|
|
19
|
+
* token included) is left exactly as it was. Because it changes state on a
|
|
20
|
+
* daemon that may not be this process's own, it states the plan and asks for
|
|
21
|
+
* confirmation before acting — `-y`/`--yes` satisfies that non-interactively,
|
|
22
|
+
* the same convention `migrate-service` uses. An unreachable daemon, a
|
|
23
|
+
* rejected token, and a daemon too old to serve the verb are each refused by
|
|
24
|
+
* name (see `callDaemonWsVerb`), never a stack trace.
|
|
25
|
+
*
|
|
26
|
+
* The remote form's mint call carries no `--name` flag of its own: the token
|
|
27
|
+
* is named with the SDK's `defaultPairingTokenName()`, the same date-stamped
|
|
28
|
+
* default every other pairing producer uses when the operator did not supply
|
|
29
|
+
* one. The name is user-visible and editable later in device management.
|
|
30
|
+
*/
|
|
31
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
32
|
+
import {
|
|
33
|
+
availablePairingOffers,
|
|
34
|
+
defaultPairingTokenName,
|
|
35
|
+
formatPairingOffers,
|
|
36
|
+
resolvePairingWebOrigin,
|
|
37
|
+
type PairingHandoffOfferKind,
|
|
38
|
+
} from '@pellux/goodvibes-sdk/platform/pairing';
|
|
39
|
+
import { renderPairingBanner } from '../core/pairing-banner.ts';
|
|
40
|
+
import {
|
|
41
|
+
extractOperatorToken,
|
|
42
|
+
resolveRemoteDaemonTarget,
|
|
43
|
+
type RemoteDaemonTarget,
|
|
44
|
+
} from '@pellux/goodvibes-terminal-shell';
|
|
45
|
+
import { callDaemonWsVerb, type DaemonWebSocketFactory } from '../cluster/daemon-ws-call.ts';
|
|
46
|
+
import type { DaemonCommandResult, RemoteCommandFlags } from './status-command.ts';
|
|
47
|
+
|
|
48
|
+
export interface PairCommandDeps {
|
|
49
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
50
|
+
readonly daemonHomeDir: string;
|
|
51
|
+
readonly version: string;
|
|
52
|
+
/** Injected in tests so nothing reads a real token file. */
|
|
53
|
+
readonly readToken: (daemonHomeDir: string) => string | undefined;
|
|
54
|
+
/** Injected in tests so the remote mint path never opens a real socket. */
|
|
55
|
+
readonly socketFactory?: DaemonWebSocketFactory | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PairCommandFlags extends RemoteCommandFlags {
|
|
59
|
+
/** `-y`/`--yes`: consent to minting a new per-device token on a remote daemon. */
|
|
60
|
+
readonly yes: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunPairCommandInput extends PairCommandDeps {
|
|
64
|
+
readonly flags: PairCommandFlags;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The hosts that mean "this machine", matching the remote-target convention. */
|
|
68
|
+
function namesThisMachine(host: string): boolean {
|
|
69
|
+
const normalized = host.trim().toLowerCase();
|
|
70
|
+
return normalized === ''
|
|
71
|
+
|| normalized === '127.0.0.1'
|
|
72
|
+
|| normalized === 'localhost'
|
|
73
|
+
|| normalized === '::1'
|
|
74
|
+
|| normalized === '0.0.0.0';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function failure(error: string, fix: string, json: boolean): DaemonCommandResult {
|
|
78
|
+
return {
|
|
79
|
+
exitCode: 1,
|
|
80
|
+
lines: json ? [JSON.stringify({ ok: false, error, fix }, null, 2)] : [error, ` ${fix}`],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The web origin a remote handoff's deep link opens, recovered from the link
|
|
86
|
+
* itself. `buildPairingHandoffLink` in the SDK builds the link as exactly
|
|
87
|
+
* `<webOrigin, trailing slashes stripped>/#<params>` — so slicing at the
|
|
88
|
+
* first `/#` is the precise inverse, and feeding the result back into
|
|
89
|
+
* `renderPairingBanner` (which rebuilds the SAME link from origin + token +
|
|
90
|
+
* offers) reproduces byte-for-byte what the remote daemon already returned.
|
|
91
|
+
*/
|
|
92
|
+
function originFromDeepLink(deepLink: string): string {
|
|
93
|
+
const cut = deepLink.indexOf('/#');
|
|
94
|
+
return cut === -1 ? deepLink : deepLink.slice(0, cut);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isPairingOfferKind(value: string): value is PairingHandoffOfferKind {
|
|
98
|
+
return value === 'notifications' || value === 'relay' || value === 'passkey';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface PairingHandoffOfferDetail {
|
|
102
|
+
readonly kind: string;
|
|
103
|
+
readonly available?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The shape `pairing.handoff.create` returns — see routes/pairing-handoff.ts. */
|
|
107
|
+
interface PairingHandoffCreateResult {
|
|
108
|
+
readonly token: {
|
|
109
|
+
readonly id: string;
|
|
110
|
+
readonly name: string;
|
|
111
|
+
/** The plaintext secret — returned exactly once. */
|
|
112
|
+
readonly token: string;
|
|
113
|
+
readonly createdAt: number;
|
|
114
|
+
};
|
|
115
|
+
readonly offers: readonly PairingHandoffOfferDetail[];
|
|
116
|
+
/** `#pair=<token>&offers=...` — present even when no web origin is configured. */
|
|
117
|
+
readonly fragment: string;
|
|
118
|
+
/** `<webOrigin>/#pair=...` — present only when that daemon has a web origin configured. */
|
|
119
|
+
readonly deepLink?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `goodvibes-daemon pair` with no `--host`, or one naming this machine. */
|
|
123
|
+
function runLocalReprint(input: RunPairCommandInput): DaemonCommandResult {
|
|
124
|
+
const { flags } = input;
|
|
125
|
+
|
|
126
|
+
const token = extractOperatorToken(input.readToken(input.daemonHomeDir));
|
|
127
|
+
if (token === undefined) {
|
|
128
|
+
return failure(
|
|
129
|
+
'no operator token was found for this machine, so there is no link to print',
|
|
130
|
+
'start the daemon once — it creates the token as it starts: goodvibes-daemon serve',
|
|
131
|
+
flags.json,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The non-writing read: `ensurePublicBaseUrl` (which the boot path uses)
|
|
136
|
+
// freezes a resolved origin into settings, and a command that only PRINTS a
|
|
137
|
+
// link has no business writing configuration as a side effect.
|
|
138
|
+
const origin = resolvePairingWebOrigin(input.configManager);
|
|
139
|
+
const offers = availablePairingOffers({
|
|
140
|
+
relayEnabled: input.configManager.get('relay.enabled') === true,
|
|
141
|
+
stepUpAvailable: true,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const banner = renderPairingBanner({
|
|
145
|
+
version: input.version,
|
|
146
|
+
origin: origin.origin,
|
|
147
|
+
token,
|
|
148
|
+
offers,
|
|
149
|
+
includeQr: !flags.json,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
return {
|
|
154
|
+
exitCode: 0,
|
|
155
|
+
lines: [JSON.stringify({
|
|
156
|
+
ok: true,
|
|
157
|
+
data: {
|
|
158
|
+
origin: origin.origin,
|
|
159
|
+
deepLink: banner.deepLink,
|
|
160
|
+
offers,
|
|
161
|
+
capabilities: banner.capabilities,
|
|
162
|
+
...(banner.notice === undefined ? {} : { notice: banner.notice }),
|
|
163
|
+
originFromPublicBaseUrl: origin.fromPublicBaseUrl,
|
|
164
|
+
},
|
|
165
|
+
}, null, 2)],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { exitCode: 0, lines: banner.lines };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The plan `pair --host <target>` prints before it will act, absent `-y`. Nothing is called yet. */
|
|
173
|
+
function mintPlanResult(target: RemoteDaemonTarget, json: boolean): DaemonCommandResult {
|
|
174
|
+
const plan = [
|
|
175
|
+
`pair --host: this will MINT A NEW per-device pairing token on the daemon at ${target.baseUrl}`,
|
|
176
|
+
'and print the pairing link/QR for that new token.',
|
|
177
|
+
'',
|
|
178
|
+
"That daemon's existing tokens — its shared token and every other paired device — are",
|
|
179
|
+
'left exactly as they are: minting is a different act than reprinting, and this is a',
|
|
180
|
+
'fresh token, not a link to one that already exists.',
|
|
181
|
+
'',
|
|
182
|
+
'Nothing has been changed. Re-run with -y (or --yes) to mint it.',
|
|
183
|
+
];
|
|
184
|
+
if (json) {
|
|
185
|
+
return {
|
|
186
|
+
exitCode: 0,
|
|
187
|
+
lines: [JSON.stringify({
|
|
188
|
+
ok: true,
|
|
189
|
+
data: { confirmed: false, target: target.baseUrl, plan },
|
|
190
|
+
}, null, 2)],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return { exitCode: 0, lines: plan };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The remote form, once `-y` has consented: mint and render. */
|
|
197
|
+
async function runRemoteMint(input: RunPairCommandInput, target: RemoteDaemonTarget): Promise<DaemonCommandResult> {
|
|
198
|
+
const { flags } = input;
|
|
199
|
+
|
|
200
|
+
const outcome = await callDaemonWsVerb<PairingHandoffCreateResult>(target, 'pairing.handoff.create', {
|
|
201
|
+
body: { name: defaultPairingTokenName() },
|
|
202
|
+
...(input.socketFactory ? { socketFactory: input.socketFactory } : {}),
|
|
203
|
+
});
|
|
204
|
+
if (!outcome.ok) return failure(outcome.error, outcome.fix, flags.json);
|
|
205
|
+
|
|
206
|
+
const data = outcome.data;
|
|
207
|
+
const offerKinds = data.offers.map((offer) => offer.kind).filter(isPairingOfferKind);
|
|
208
|
+
const mintedLine = `minted a new per-device pairing token ("${data.token.name}") on the daemon at ${target.baseUrl}.`;
|
|
209
|
+
|
|
210
|
+
if (data.deepLink === undefined) {
|
|
211
|
+
// Honest degraded path: the mint itself succeeded — the token is real and
|
|
212
|
+
// usable — but that daemon has no web origin configured, and this process
|
|
213
|
+
// has no way to read or fabricate one for it. `resolvePairingWebOrigin` is
|
|
214
|
+
// what the local form reads instead, and it is exactly the thing this
|
|
215
|
+
// process cannot ask a REMOTE daemon for outside this verb's own reply.
|
|
216
|
+
const lines = [
|
|
217
|
+
mintedLine,
|
|
218
|
+
'',
|
|
219
|
+
'that daemon has no web origin configured, so no deep link or QR could be built — only',
|
|
220
|
+
'the raw token and pairing fragment:',
|
|
221
|
+
'',
|
|
222
|
+
` token: ${data.token.token}`,
|
|
223
|
+
` fragment: ${data.fragment}`,
|
|
224
|
+
'',
|
|
225
|
+
...(offerKinds.length > 0 ? ['Offers (each declinable in the web app):', ...formatPairingOffers(offerKinds), ''] : []),
|
|
226
|
+
'Configure a web origin on that daemon, then pair again for a scannable link.',
|
|
227
|
+
];
|
|
228
|
+
if (flags.json) {
|
|
229
|
+
return {
|
|
230
|
+
exitCode: 0,
|
|
231
|
+
lines: [JSON.stringify({
|
|
232
|
+
ok: true,
|
|
233
|
+
data: {
|
|
234
|
+
minted: true,
|
|
235
|
+
target: target.baseUrl,
|
|
236
|
+
tokenId: data.token.id,
|
|
237
|
+
tokenName: data.token.name,
|
|
238
|
+
token: data.token.token,
|
|
239
|
+
fragment: data.fragment,
|
|
240
|
+
offers: offerKinds,
|
|
241
|
+
},
|
|
242
|
+
}, null, 2)],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return { exitCode: 0, lines };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const origin = originFromDeepLink(data.deepLink);
|
|
249
|
+
const banner = renderPairingBanner({
|
|
250
|
+
version: `remote build at ${target.baseUrl}`,
|
|
251
|
+
origin,
|
|
252
|
+
token: data.token.token,
|
|
253
|
+
offers: offerKinds,
|
|
254
|
+
includeQr: !flags.json,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
if (flags.json) {
|
|
258
|
+
return {
|
|
259
|
+
exitCode: 0,
|
|
260
|
+
lines: [JSON.stringify({
|
|
261
|
+
ok: true,
|
|
262
|
+
data: {
|
|
263
|
+
minted: true,
|
|
264
|
+
target: target.baseUrl,
|
|
265
|
+
tokenId: data.token.id,
|
|
266
|
+
tokenName: data.token.name,
|
|
267
|
+
token: data.token.token,
|
|
268
|
+
origin,
|
|
269
|
+
deepLink: banner.deepLink,
|
|
270
|
+
offers: offerKinds,
|
|
271
|
+
capabilities: banner.capabilities,
|
|
272
|
+
...(banner.notice === undefined ? {} : { notice: banner.notice }),
|
|
273
|
+
},
|
|
274
|
+
}, null, 2)],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { exitCode: 0, lines: [mintedLine, '', ...banner.lines] };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function runPairCommand(input: RunPairCommandInput): Promise<DaemonCommandResult> {
|
|
282
|
+
const { flags } = input;
|
|
283
|
+
|
|
284
|
+
if (flags.host !== undefined && !namesThisMachine(flags.host)) {
|
|
285
|
+
const resolved = resolveRemoteDaemonTarget({
|
|
286
|
+
flags: {
|
|
287
|
+
host: flags.host,
|
|
288
|
+
...(flags.port === undefined ? {} : { port: flags.port }),
|
|
289
|
+
...(flags.token === undefined ? {} : { token: flags.token }),
|
|
290
|
+
},
|
|
291
|
+
configManager: input.configManager,
|
|
292
|
+
daemonHomeDir: input.daemonHomeDir,
|
|
293
|
+
readToken: input.readToken,
|
|
294
|
+
});
|
|
295
|
+
if (!resolved.ok) return failure(resolved.error, resolved.fix, flags.json);
|
|
296
|
+
if (!flags.yes) return mintPlanResult(resolved.target, flags.json);
|
|
297
|
+
return runRemoteMint(input, resolved.target);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return runLocalReprint(input);
|
|
301
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// provision-wake-model.ts — `goodvibes-daemon provision-wake-model`.
|
|
3
|
+
//
|
|
4
|
+
// WHAT THIS IS FOR
|
|
5
|
+
//
|
|
6
|
+
// The curl installer needs to put the wake-word model on the machine, and it must
|
|
7
|
+
// not hold the pinned URLs, byte counts or checksums to do it. Those live in ONE
|
|
8
|
+
// place — the SDK's wake-word manifest — and a shell script that copied them
|
|
9
|
+
// would be a second copy of a pin, drifting silently the first time the model is
|
|
10
|
+
// retrained. So the installer runs the binary it just installed and lets the SDK
|
|
11
|
+
// do what the SDK owns.
|
|
12
|
+
//
|
|
13
|
+
// IT EXITS 0 EVEN WHEN THE DOWNLOAD FAILS, ON PURPOSE
|
|
14
|
+
//
|
|
15
|
+
// The caller is an installer. A wake-word model is not a reason to fail installing
|
|
16
|
+
// a coding tool, and an installer that aborts half-way through is worse than one
|
|
17
|
+
// that finishes without a wake word. The outcome is printed either way — one plain
|
|
18
|
+
// line naming what happened and how to retry — and a running daemon retries at
|
|
19
|
+
// every boot. `--strict` is there for a caller that genuinely wants the exit code
|
|
20
|
+
// to carry the result (a test, a provisioning script that is checking); the
|
|
21
|
+
// installer does not pass it.
|
|
22
|
+
//
|
|
23
|
+
// It composes NOTHING. No runtime, no config manager, no gateway: this reads a
|
|
24
|
+
// home directory, derives the managed voice root the same way the running daemon
|
|
25
|
+
// does, and calls one SDK function. Composing a runtime here would start a second
|
|
26
|
+
// set of pollers on a machine whose daemon is probably already running.
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
provisionWakeWordModelsAtInstall,
|
|
31
|
+
resolveManagedVoiceRoot,
|
|
32
|
+
} from '@pellux/goodvibes-sdk/platform/voice';
|
|
33
|
+
|
|
34
|
+
export interface ProvisionWakeModelResult {
|
|
35
|
+
readonly exitCode: number;
|
|
36
|
+
readonly lines: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ProvisionWakeModelDeps {
|
|
40
|
+
/** The home directory whose `.goodvibes/voice` tree receives the artifacts. */
|
|
41
|
+
readonly homeDirectory: string;
|
|
42
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
43
|
+
/** Injected in tests: the SDK policy, so nothing downloads. */
|
|
44
|
+
readonly provisionAtInstall?: typeof provisionWakeWordModelsAtInstall | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the install-time provision and report it.
|
|
49
|
+
*
|
|
50
|
+
* `--strict` makes a degraded outcome exit 1. Without it, every outcome exits 0,
|
|
51
|
+
* because the caller is an installer and the alternative is an aborted install
|
|
52
|
+
* over an optional model.
|
|
53
|
+
*/
|
|
54
|
+
export async function runProvisionWakeModelCommand(
|
|
55
|
+
argv: readonly string[],
|
|
56
|
+
deps: ProvisionWakeModelDeps,
|
|
57
|
+
): Promise<ProvisionWakeModelResult> {
|
|
58
|
+
const strict = argv.includes('--strict');
|
|
59
|
+
const provision = deps.provisionAtInstall ?? provisionWakeWordModelsAtInstall;
|
|
60
|
+
let managedRoot: string;
|
|
61
|
+
try {
|
|
62
|
+
managedRoot = resolveManagedVoiceRoot(deps.homeDirectory);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
// A home directory this process cannot make sense of is still not a reason to
|
|
65
|
+
// fail an install, so it is reported on the same terms as a failed download.
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
return {
|
|
68
|
+
exitCode: strict ? 1 : 0,
|
|
69
|
+
lines: [`wake-word model: skipped — ${message}`],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const outcome = await provision({
|
|
73
|
+
managedRoot,
|
|
74
|
+
recoveryHint: '/voice wake setup',
|
|
75
|
+
...(deps.env !== undefined ? { env: deps.env } : {}),
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
exitCode: strict && outcome.state === 'degraded' ? 1 : 0,
|
|
79
|
+
lines: [`wake-word model: ${outcome.message}`],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels.ts — which channels `goodvibes-daemon send` can reach, and which one
|
|
3
|
+
* it uses when the operator names none.
|
|
4
|
+
*
|
|
5
|
+
* The surface list, labels and required-setup keys are IMPORTED from
|
|
6
|
+
* `src/cli/surface-catalog.ts` rather than restated, so `goodvibes surfaces
|
|
7
|
+
* list` and `goodvibes-daemon send --list` can never disagree about what a
|
|
8
|
+
* channel is called or what it needs. What this module adds on top is the two
|
|
9
|
+
* facts that command needs and `SURFACE_CONFIGS` does not carry: the routable
|
|
10
|
+
* `ChannelDeliverySurfaceKind` for each surface id (they differ in case —
|
|
11
|
+
* `googleChat` vs `google-chat`), and the settings key holding each surface's
|
|
12
|
+
* default destination.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ConfigKey, ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
16
|
+
import type { ChannelDeliverySurfaceKind } from '@pellux/goodvibes-sdk/platform/channels';
|
|
17
|
+
import { SURFACE_CONFIGS } from '../../cli/surface-catalog.ts';
|
|
18
|
+
import { canRenderInert } from './inert-text.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The settings key holding each supported surface's default destination — the
|
|
22
|
+
* chat, channel, topic or URL a message goes to when `--to` is not given.
|
|
23
|
+
*
|
|
24
|
+
* These are the SAME keys the delivery strategies in the SDK's
|
|
25
|
+
* `strategies-core.ts` fall back to (`surfaces.telegram.defaultChatId`,
|
|
26
|
+
* `surfaces.discord.defaultChannelId`, …). Only surfaces with a verified inert
|
|
27
|
+
* transform appear; see inert-text.ts for why the rest are refused rather than
|
|
28
|
+
* sent to.
|
|
29
|
+
*/
|
|
30
|
+
const DESTINATION_KEY_BY_SURFACE_ID = {
|
|
31
|
+
telegram: 'surfaces.telegram.defaultChatId',
|
|
32
|
+
ntfy: 'surfaces.ntfy.topic',
|
|
33
|
+
discord: 'surfaces.discord.defaultChannelId',
|
|
34
|
+
slack: 'surfaces.slack.defaultChannel',
|
|
35
|
+
googleChat: 'surfaces.googleChat.webhookUrl',
|
|
36
|
+
webhook: 'surfaces.webhook.defaultTarget',
|
|
37
|
+
signal: 'surfaces.signal.defaultRecipient',
|
|
38
|
+
whatsapp: 'surfaces.whatsapp.defaultRecipient',
|
|
39
|
+
imessage: 'surfaces.imessage.defaultChatId',
|
|
40
|
+
msteams: 'surfaces.msteams.defaultConversationId',
|
|
41
|
+
bluebubbles: 'surfaces.bluebubbles.defaultChatGuid',
|
|
42
|
+
mattermost: 'surfaces.mattermost.defaultChannelId',
|
|
43
|
+
matrix: 'surfaces.matrix.defaultRoomId',
|
|
44
|
+
// Not cast: every value is checked against the real ConfigKey union, so a
|
|
45
|
+
// key that is renamed or misspelled in the schema fails the build here
|
|
46
|
+
// instead of reading `undefined` at send time and reporting the channel as
|
|
47
|
+
// unconfigured.
|
|
48
|
+
} as const satisfies Readonly<Record<string, ConfigKey>>;
|
|
49
|
+
|
|
50
|
+
/** Surface ids whose routable kind is not simply the id itself. */
|
|
51
|
+
const SURFACE_KIND_BY_ID: Readonly<Record<string, ChannelDeliverySurfaceKind>> = {
|
|
52
|
+
googleChat: 'google-chat',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `--to` addresses on each channel, in that channel's own vocabulary.
|
|
57
|
+
*
|
|
58
|
+
* This is naming, not a scheme: `--to` becomes `ChannelDeliveryTarget.address`,
|
|
59
|
+
* which is the first thing every strategy in `strategies-core.ts`,
|
|
60
|
+
* `strategies-bridge.ts` and `strategies-enterprise.ts` already checks before
|
|
61
|
+
* falling back to its configured default. Nothing new is invented — the label
|
|
62
|
+
* exists so `--list` and the help can say "topic" where the channel says topic
|
|
63
|
+
* and "chat id" where it says chat id, rather than making the operator work out
|
|
64
|
+
* what an "address" is for ntfy.
|
|
65
|
+
*/
|
|
66
|
+
const ADDRESS_LABEL_BY_SURFACE_ID: Readonly<Record<string, string>> = {
|
|
67
|
+
telegram: 'chat id',
|
|
68
|
+
ntfy: 'topic',
|
|
69
|
+
discord: 'channel id',
|
|
70
|
+
slack: 'channel id',
|
|
71
|
+
googleChat: 'webhook URL',
|
|
72
|
+
webhook: 'URL',
|
|
73
|
+
signal: 'recipient',
|
|
74
|
+
whatsapp: 'recipient',
|
|
75
|
+
imessage: 'chat id',
|
|
76
|
+
msteams: 'conversation id',
|
|
77
|
+
bluebubbles: 'chat GUID',
|
|
78
|
+
mattermost: 'channel id',
|
|
79
|
+
matrix: 'room id',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface SendChannel {
|
|
83
|
+
/** The id an operator types after `--channel`, e.g. `telegram`. */
|
|
84
|
+
readonly id: string;
|
|
85
|
+
/** Human label, shared with `goodvibes surfaces list`. */
|
|
86
|
+
readonly label: string;
|
|
87
|
+
/** The kind the channel delivery router routes on. */
|
|
88
|
+
readonly surfaceKind: ChannelDeliverySurfaceKind;
|
|
89
|
+
/** `surfaces.<id>.enabled`. */
|
|
90
|
+
readonly enabledKey: ConfigKey;
|
|
91
|
+
/** The settings key holding this channel's default destination. */
|
|
92
|
+
readonly destinationKey: ConfigKey;
|
|
93
|
+
/** What `--to` names on this channel, in the channel's own words ("topic"). */
|
|
94
|
+
readonly addressLabel: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every channel this command can deliver to. Derived, not restated: a surface
|
|
99
|
+
* that gains a delivery strategy and an inert transform appears here by adding
|
|
100
|
+
* its destination key above, and a surface that loses its entry in
|
|
101
|
+
* `SURFACE_CONFIGS` disappears from both commands at once.
|
|
102
|
+
*/
|
|
103
|
+
const destinationKeys: Readonly<Record<string, ConfigKey>> = DESTINATION_KEY_BY_SURFACE_ID;
|
|
104
|
+
|
|
105
|
+
export const SEND_CHANNELS: readonly SendChannel[] = SURFACE_CONFIGS
|
|
106
|
+
.flatMap(([id, label]): SendChannel[] => {
|
|
107
|
+
const destinationKey = destinationKeys[id];
|
|
108
|
+
if (!destinationKey) return [];
|
|
109
|
+
const surfaceKind = SURFACE_KIND_BY_ID[id] ?? (id as ChannelDeliverySurfaceKind);
|
|
110
|
+
if (!canRenderInert(surfaceKind)) return [];
|
|
111
|
+
return [{
|
|
112
|
+
id,
|
|
113
|
+
label,
|
|
114
|
+
surfaceKind,
|
|
115
|
+
enabledKey: `surfaces.${id}.enabled` as ConfigKey,
|
|
116
|
+
destinationKey,
|
|
117
|
+
addressLabel: ADDRESS_LABEL_BY_SURFACE_ID[id] ?? 'address',
|
|
118
|
+
}];
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Look a channel up by the id an operator typed, accepting either the settings
|
|
123
|
+
* id (`googleChat`) or the routable kind (`google-chat`) — the two spellings
|
|
124
|
+
* are both visible in this product's own output, and making the operator
|
|
125
|
+
* remember which one this command wants would be a trap.
|
|
126
|
+
*/
|
|
127
|
+
export function findSendChannel(name: string): SendChannel | undefined {
|
|
128
|
+
const wanted = name.trim().toLowerCase();
|
|
129
|
+
return SEND_CHANNELS.find((channel) =>
|
|
130
|
+
channel.id.toLowerCase() === wanted || channel.surfaceKind.toLowerCase() === wanted);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ChannelReadiness {
|
|
134
|
+
readonly channel: SendChannel;
|
|
135
|
+
/** `surfaces.<id>.enabled` is true. */
|
|
136
|
+
readonly enabled: boolean;
|
|
137
|
+
/** The configured default destination, or null when the key is blank. */
|
|
138
|
+
readonly destination: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readSetting(config: Pick<ConfigManager, 'get'>, key: ConfigKey): string {
|
|
142
|
+
const value = config.get(key);
|
|
143
|
+
return typeof value === 'string' ? value.trim() : value === undefined || value === null ? '' : String(value).trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read every channel's live state from config.
|
|
148
|
+
*
|
|
149
|
+
* The `ConfigManager` handed in must be one built with a `homeDir`, because
|
|
150
|
+
* every `surfaces.*` key is daemon-owned: the manager overlays
|
|
151
|
+
* `<home>/.goodvibes/daemon/settings.json` LAST, which is the only reason a
|
|
152
|
+
* client process can see a bot token the daemon owns. A manager built without
|
|
153
|
+
* that overlay reads a surface silo holding nothing but `setupVersion` and
|
|
154
|
+
* reports every channel as unconfigured.
|
|
155
|
+
*/
|
|
156
|
+
export function readChannelReadiness(config: Pick<ConfigManager, 'get'>): readonly ChannelReadiness[] {
|
|
157
|
+
return SEND_CHANNELS.map((channel) => {
|
|
158
|
+
const destination = readSetting(config, channel.destinationKey);
|
|
159
|
+
return {
|
|
160
|
+
channel,
|
|
161
|
+
enabled: config.get(channel.enabledKey) === true,
|
|
162
|
+
destination: destination.length > 0 ? destination : null,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type DefaultChannelResolution =
|
|
168
|
+
| { readonly kind: 'resolved'; readonly channel: SendChannel; readonly destination: string; readonly reason: string }
|
|
169
|
+
| { readonly kind: 'none'; readonly candidates: readonly ChannelReadiness[] }
|
|
170
|
+
| { readonly kind: 'ambiguous'; readonly candidates: readonly ChannelReadiness[] };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Which channel a `send` with no `--channel` goes to.
|
|
174
|
+
*
|
|
175
|
+
* A channel qualifies when it is switched on AND has a destination configured —
|
|
176
|
+
* "enabled" alone is not enough, because an enabled surface with a blank
|
|
177
|
+
* destination is a channel that would throw at the provider rather than deliver.
|
|
178
|
+
*
|
|
179
|
+
* **Ambiguity refuses; it never picks.** With two qualifying channels there is
|
|
180
|
+
* no non-arbitrary winner, and this command has an outward effect: sending the
|
|
181
|
+
* owner's message to the wrong one of his channels is worse than printing the
|
|
182
|
+
* list and exiting non-zero. There is deliberately no priority order here to
|
|
183
|
+
* silently break that tie — a preference ordering baked into this file would be
|
|
184
|
+
* an invisible decision about where his messages go.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveDefaultChannel(config: Pick<ConfigManager, 'get'>): DefaultChannelResolution {
|
|
187
|
+
const readiness = readChannelReadiness(config);
|
|
188
|
+
const qualifying = readiness.filter((entry) => entry.enabled && entry.destination !== null);
|
|
189
|
+
if (qualifying.length === 1) {
|
|
190
|
+
const only = qualifying[0]!;
|
|
191
|
+
return {
|
|
192
|
+
kind: 'resolved',
|
|
193
|
+
channel: only.channel,
|
|
194
|
+
destination: only.destination!,
|
|
195
|
+
reason: 'the only channel that is switched on and has a destination configured',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (qualifying.length === 0) return { kind: 'none', candidates: readiness };
|
|
199
|
+
return { kind: 'ambiguous', candidates: qualifying };
|
|
200
|
+
}
|