@vellumai/assistant 0.11.5 → 0.11.6-staging.1
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/AGENTS.md +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { Command } from "commander";
|
|
16
16
|
|
|
17
17
|
import { cliIpcCall, exitFromIpcResult } from "../../../ipc/cli-client.js";
|
|
18
|
+
import type { ChannelReadinessSnapshot } from "../../../runtime/channel-readiness-types.js";
|
|
18
19
|
import { applyCommandHelp, subcommand } from "../../lib/cli-command-help.js";
|
|
19
20
|
import { registerCommand } from "../../lib/register-command.js";
|
|
20
21
|
import { log } from "../../logger.js";
|
|
@@ -22,26 +23,16 @@ import { shouldOutputJson, writeOutput } from "../../output.js";
|
|
|
22
23
|
import { channelsHelp } from "./index.help.js";
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
25
|
-
// Snapshot shape
|
|
26
|
+
// Snapshot shape
|
|
26
27
|
// ---------------------------------------------------------------------------
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
channel: string;
|
|
36
|
-
ready: boolean;
|
|
37
|
-
setupStatus: "not_configured" | "incomplete" | "ready";
|
|
38
|
-
checkedAt: number;
|
|
39
|
-
stale: boolean;
|
|
40
|
-
reasons: Array<{ code: string; text: string }>;
|
|
41
|
-
localChecks: ReadinessCheck[];
|
|
42
|
-
remoteChecks?: ReadinessCheck[];
|
|
43
|
-
channelHandle?: unknown;
|
|
44
|
-
}
|
|
29
|
+
/**
|
|
30
|
+
* The readiness service's own snapshot type, not a copy of it. A local
|
|
31
|
+
* mirror is what let this command fall a field behind the contract: it had
|
|
32
|
+
* no `health`, so a channel whose setup was complete and whose delivery had
|
|
33
|
+
* stopped rendered as unfinished setup.
|
|
34
|
+
*/
|
|
35
|
+
type ChannelSnapshot = ChannelReadinessSnapshot;
|
|
45
36
|
|
|
46
37
|
interface ReadinessResponse {
|
|
47
38
|
success: boolean;
|
|
@@ -52,26 +43,49 @@ interface ReadinessResponse {
|
|
|
52
43
|
// Rendering
|
|
53
44
|
// ---------------------------------------------------------------------------
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
function
|
|
46
|
+
/**
|
|
47
|
+
* Two axes, reported as one line each.
|
|
48
|
+
*
|
|
49
|
+
* `setupStatus` says whether setup finished; `health` says whether the
|
|
50
|
+
* channel is delivering. Collapsing them into setup progress alone is how a
|
|
51
|
+
* configured channel whose socket had died came to print as `incomplete`,
|
|
52
|
+
* which tells the reader to go finish a setup that is already complete.
|
|
53
|
+
* Ordered so the operational answer wins: once setup is done, what remains
|
|
54
|
+
* to say is whether it works.
|
|
55
|
+
*/
|
|
56
|
+
function statusState(
|
|
57
|
+
s: ChannelSnapshot,
|
|
58
|
+
):
|
|
59
|
+
| "ready"
|
|
60
|
+
| "not configured"
|
|
61
|
+
| "not delivering"
|
|
62
|
+
| "state unknown"
|
|
63
|
+
| "incomplete" {
|
|
66
64
|
if (s.ready) {
|
|
67
65
|
return "ready";
|
|
68
66
|
}
|
|
69
67
|
if (s.setupStatus === "not_configured") {
|
|
70
68
|
return "not configured";
|
|
71
69
|
}
|
|
70
|
+
if (s.setupStatus === "ready") {
|
|
71
|
+
return s.health === "unknown" ? "state unknown" : "not delivering";
|
|
72
|
+
}
|
|
72
73
|
return "incomplete";
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
const STATE_GLYPHS: Record<ReturnType<typeof statusState>, string> = {
|
|
77
|
+
ready: "✅",
|
|
78
|
+
"not configured": "○ ",
|
|
79
|
+
"not delivering": "⚠️ ",
|
|
80
|
+
// Neither vouched for nor condemned: the checks ran and settled nothing.
|
|
81
|
+
"state unknown": "? ",
|
|
82
|
+
incomplete: "⚠️ ",
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function statusGlyph(s: ChannelSnapshot): string {
|
|
86
|
+
return STATE_GLYPHS[statusState(s)];
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
function renderList(snapshots: ChannelSnapshot[]): void {
|
|
76
90
|
const sorted = [...snapshots].sort((a, b) =>
|
|
77
91
|
a.channel.localeCompare(b.channel),
|
|
@@ -79,12 +93,12 @@ function renderList(snapshots: ChannelSnapshot[]): void {
|
|
|
79
93
|
log.info("Channel Status");
|
|
80
94
|
log.info("------------- ------");
|
|
81
95
|
for (const s of sorted) {
|
|
82
|
-
log.info(`${statusGlyph(s)} ${s.channel.padEnd(12)} ${
|
|
96
|
+
log.info(`${statusGlyph(s)} ${s.channel.padEnd(12)} ${statusState(s)}`);
|
|
83
97
|
}
|
|
84
98
|
}
|
|
85
99
|
|
|
86
100
|
function renderSnapshot(s: ChannelSnapshot): void {
|
|
87
|
-
log.info(`${statusGlyph(s)} ${s.channel} — ${s
|
|
101
|
+
log.info(`${statusGlyph(s)} ${s.channel} — ${statusState(s)}`);
|
|
88
102
|
if (s.reasons.length > 0) {
|
|
89
103
|
log.info("");
|
|
90
104
|
log.info("Reasons:");
|
|
@@ -28,6 +28,8 @@ interface ProfileSummary {
|
|
|
28
28
|
source: "managed" | "user";
|
|
29
29
|
provider_connection?: string;
|
|
30
30
|
availability: { status: string; message?: string } | null;
|
|
31
|
+
/** Static problem with the stored entry itself; absent when it checks out. */
|
|
32
|
+
config_issue?: { code: string; message: string };
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
interface ProfileWriteResult {
|
|
@@ -37,6 +39,29 @@ interface ProfileWriteResult {
|
|
|
37
39
|
warnings: string[];
|
|
38
40
|
/** Live-call command the daemon suggests for verifying the written profile. */
|
|
39
41
|
verify?: string;
|
|
42
|
+
/** Save-time probe verdict (null = no verdict); absent when the daemon predates the route. */
|
|
43
|
+
check?: ProfileCheck | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ProfileCheck {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
blame?: string;
|
|
49
|
+
message?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Probe the just-written profile with one minimal request through the
|
|
54
|
+
* daemon's validate route. Advisory: any transport or route error (e.g. a
|
|
55
|
+
* daemon predating the route) yields undefined and the save flow proceeds.
|
|
56
|
+
*/
|
|
57
|
+
async function fetchProfileCheck(
|
|
58
|
+
name: string,
|
|
59
|
+
): Promise<ProfileCheck | null | undefined> {
|
|
60
|
+
const probe = await cliIpcCall<{ check: ProfileCheck | null }>(
|
|
61
|
+
"inference_profiles_validate",
|
|
62
|
+
{ pathParams: { name } },
|
|
63
|
+
);
|
|
64
|
+
return probe.ok ? probe.result!.check : undefined;
|
|
40
65
|
}
|
|
41
66
|
|
|
42
67
|
type WriteFlags = {
|
|
@@ -123,6 +148,9 @@ function printWriteResult(
|
|
|
123
148
|
for (const warning of result.warnings) {
|
|
124
149
|
writeLine(`warning: ${warning}`);
|
|
125
150
|
}
|
|
151
|
+
if (result.check && !result.check.ok && result.check.message) {
|
|
152
|
+
writeLine(`warning: ${result.check.message}`);
|
|
153
|
+
}
|
|
126
154
|
writeLine(`profile ${result.name} ${verb}`);
|
|
127
155
|
if (result.verify) {
|
|
128
156
|
writeLine(`Verify it works: ${result.verify}`);
|
|
@@ -152,7 +180,16 @@ export function attachProfilesSubcommand(inference: Command): void {
|
|
|
152
180
|
return;
|
|
153
181
|
}
|
|
154
182
|
renderTable(
|
|
155
|
-
[
|
|
183
|
+
[
|
|
184
|
+
"NAME",
|
|
185
|
+
"LABEL",
|
|
186
|
+
"PROVIDER",
|
|
187
|
+
"MODEL",
|
|
188
|
+
"STATUS",
|
|
189
|
+
"SOURCE",
|
|
190
|
+
"AVAIL",
|
|
191
|
+
"CONFIG",
|
|
192
|
+
],
|
|
156
193
|
rows.map((p) => [
|
|
157
194
|
p.name,
|
|
158
195
|
p.label ?? "-",
|
|
@@ -161,6 +198,7 @@ export function attachProfilesSubcommand(inference: Command): void {
|
|
|
161
198
|
p.status,
|
|
162
199
|
p.source,
|
|
163
200
|
p.availability ? p.availability.status : "-",
|
|
201
|
+
p.config_issue ? p.config_issue.code : "ok",
|
|
164
202
|
]),
|
|
165
203
|
);
|
|
166
204
|
});
|
|
@@ -172,6 +210,7 @@ export function attachProfilesSubcommand(inference: Command): void {
|
|
|
172
210
|
name: string;
|
|
173
211
|
entry: Record<string, unknown>;
|
|
174
212
|
availability: { status: string; message?: string } | null;
|
|
213
|
+
config_issue?: { code: string; message: string };
|
|
175
214
|
}>("inference_profiles_get", { pathParams: { name } });
|
|
176
215
|
if (!ipcResult.ok) {
|
|
177
216
|
writeCliError(ipcResult.error ?? "Unknown error", opts.json);
|
|
@@ -192,6 +231,10 @@ export function attachProfilesSubcommand(inference: Command): void {
|
|
|
192
231
|
writeLine(` ${result.availability.message}`);
|
|
193
232
|
}
|
|
194
233
|
}
|
|
234
|
+
if (result.config_issue) {
|
|
235
|
+
writeLine(` config: ${result.config_issue.code}`);
|
|
236
|
+
writeLine(` ${result.config_issue.message}`);
|
|
237
|
+
}
|
|
195
238
|
},
|
|
196
239
|
);
|
|
197
240
|
|
|
@@ -219,7 +262,12 @@ export function attachProfilesSubcommand(inference: Command): void {
|
|
|
219
262
|
writeCliError(ipcResult.error ?? "Unknown error", opts.json);
|
|
220
263
|
return;
|
|
221
264
|
}
|
|
222
|
-
|
|
265
|
+
const check = await fetchProfileCheck(name);
|
|
266
|
+
printWriteResult(
|
|
267
|
+
"created",
|
|
268
|
+
{ ...ipcResult.result!, ...(check !== undefined ? { check } : {}) },
|
|
269
|
+
opts.json,
|
|
270
|
+
);
|
|
223
271
|
},
|
|
224
272
|
);
|
|
225
273
|
|
|
@@ -246,7 +294,12 @@ export function attachProfilesSubcommand(inference: Command): void {
|
|
|
246
294
|
writeCliError(ipcResult.error ?? "Unknown error", opts.json);
|
|
247
295
|
return;
|
|
248
296
|
}
|
|
249
|
-
|
|
297
|
+
const check = await fetchProfileCheck(name);
|
|
298
|
+
printWriteResult(
|
|
299
|
+
"updated",
|
|
300
|
+
{ ...ipcResult.result!, ...(check !== undefined ? { check } : {}) },
|
|
301
|
+
opts.json,
|
|
302
|
+
);
|
|
250
303
|
},
|
|
251
304
|
);
|
|
252
305
|
|
|
@@ -23,6 +23,7 @@ import { cliIpcCall } from "../../ipc/cli-client.js";
|
|
|
23
23
|
import type { OAuth2Config } from "../../security/oauth2.js";
|
|
24
24
|
import { subcommand } from "../lib/cli-command-help.js";
|
|
25
25
|
import { writeCliError } from "../lib/cli-output.js";
|
|
26
|
+
import { openInHostBrowser } from "../lib/open-browser.js";
|
|
26
27
|
import { attachDefaultProviderSubcommand } from "./inference-providers-default.js";
|
|
27
28
|
|
|
28
29
|
// ---------------------------------------------------------------------------
|
|
@@ -41,6 +42,22 @@ interface ProviderConnection {
|
|
|
41
42
|
baseUrl?: string | null;
|
|
42
43
|
createdAt: string;
|
|
43
44
|
updatedAt: string;
|
|
45
|
+
/** Save-time probe of a custom base URL; advisory, never blocks the save. */
|
|
46
|
+
endpoint_check?: {
|
|
47
|
+
ok: boolean;
|
|
48
|
+
status?: number;
|
|
49
|
+
resolved_url: string;
|
|
50
|
+
hint?: string;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Human-mode warning line for a failed save-time endpoint probe. */
|
|
55
|
+
function formatEndpointCheckWarning(conn: ProviderConnection): string {
|
|
56
|
+
const check = conn.endpoint_check;
|
|
57
|
+
if (!check || check.ok) {
|
|
58
|
+
return "";
|
|
59
|
+
}
|
|
60
|
+
return `Warning: ${check.hint ?? `The endpoint returned HTTP ${check.status} for a test request.`} (probed ${check.resolved_url})\n`;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
// ---------------------------------------------------------------------------
|
|
@@ -304,6 +321,7 @@ function attachCreateSubcommand(parent: Command): void {
|
|
|
304
321
|
} else {
|
|
305
322
|
process.stdout.write(
|
|
306
323
|
`Added provider "${conn.name}" (provider=${conn.provider})\n` +
|
|
324
|
+
formatEndpointCheckWarning(conn) +
|
|
307
325
|
`Verify it works: point a profile at "${conn.name}", then run: assistant inference send --profile <profile> "Reply with OK"\n`,
|
|
308
326
|
);
|
|
309
327
|
}
|
|
@@ -394,6 +412,7 @@ function attachUpdateSubcommand(parent: Command): void {
|
|
|
394
412
|
} else {
|
|
395
413
|
process.stdout.write(
|
|
396
414
|
`Updated provider "${name}" (auth=${formatAuth(conn.auth)})\n` +
|
|
415
|
+
formatEndpointCheckWarning(conn) +
|
|
397
416
|
`Verify it works: assistant inference send --profile <profile-using-this-provider> "Reply with OK"\n`,
|
|
398
417
|
);
|
|
399
418
|
}
|
|
@@ -456,12 +475,19 @@ function attachLoginChatgptSubcommand(providers: Command): void {
|
|
|
456
475
|
import("../../security/secure-keys.js"),
|
|
457
476
|
]);
|
|
458
477
|
// Step 1: Run browser-based PKCE OAuth flow
|
|
459
|
-
|
|
478
|
+
if (!opts.json) {
|
|
479
|
+
process.stdout.write(
|
|
480
|
+
"Opening browser for ChatGPT authentication...\n",
|
|
481
|
+
);
|
|
482
|
+
}
|
|
460
483
|
const result = await startOAuth2Flow(
|
|
461
484
|
OPENAI_CODEX_OAUTH_CONFIG,
|
|
462
485
|
{
|
|
463
486
|
openUrl: (url) => {
|
|
464
|
-
|
|
487
|
+
openInHostBrowser(url);
|
|
488
|
+
const fallbackMessage = `If the browser did not open, visit:\n${url}\n`;
|
|
489
|
+
const output = opts.json ? process.stderr : process.stdout;
|
|
490
|
+
output.write(fallbackMessage);
|
|
465
491
|
},
|
|
466
492
|
},
|
|
467
493
|
{
|
|
@@ -776,6 +776,11 @@ the same provider will resolve against the wrong host or path and fail (often
|
|
|
776
776
|
with an opaque HTML 404). When in doubt, pass an absolute URL: it is the safe
|
|
777
777
|
form for any service other than the provider's default.
|
|
778
778
|
|
|
779
|
+
The body (-d) is JSON by default. Give a non-JSON Content-Type and the body
|
|
780
|
+
travels to the provider exactly as written, which is what multipart, XML, and
|
|
781
|
+
form-encoded payloads need. Body files (-d @<path>) are read as UTF-8 text, so
|
|
782
|
+
a payload that is not valid UTF-8 cannot be sent this way.
|
|
783
|
+
|
|
779
784
|
Note: The Authorization header is set automatically. User-supplied
|
|
780
785
|
-H "Authorization: ..." will be overridden by the OAuth bearer token.
|
|
781
786
|
|
|
@@ -784,7 +789,8 @@ Examples:
|
|
|
784
789
|
$ assistant oauth request --provider google /gmail/v1/users/me/messages -G
|
|
785
790
|
$ assistant oauth request --provider twitter -X POST -d '{"text":"Hello"}' https://api.x.com/2/tweets
|
|
786
791
|
$ assistant oauth request --provider google https://www.googleapis.com/calendar/v3/calendars/primary/events
|
|
787
|
-
$ assistant oauth request --provider slack -H "Content-Type: application/json" -d '{"channel":"C123"}' /api/chat.postMessage --json
|
|
792
|
+
$ assistant oauth request --provider slack -H "Content-Type: application/json" -d '{"channel":"C123"}' /api/chat.postMessage --json
|
|
793
|
+
$ assistant oauth request --provider google -X POST -H "Content-Type: multipart/related; boundary=b" -d @upload.txt "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart"`,
|
|
788
794
|
},
|
|
789
795
|
{
|
|
790
796
|
name: "disconnect",
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
5
|
+
|
|
6
|
+
const PNG_MAGIC = Buffer.from([
|
|
7
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe, 0x00,
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
const ipcCalls: string[] = [];
|
|
11
|
+
const handleRequestCalls: unknown[] = [];
|
|
12
|
+
|
|
13
|
+
let handleRequestResult: {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
status: number;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
body: unknown;
|
|
18
|
+
bodyEncoding?: "base64";
|
|
19
|
+
account?: string | null;
|
|
20
|
+
} = {
|
|
21
|
+
ok: true,
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
body: { hello: "world" },
|
|
25
|
+
account: "user@example.com",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
mock.module("../../../ipc/cli-client.js", () => ({
|
|
29
|
+
cliIpcCall: async (method: string) => {
|
|
30
|
+
ipcCalls.push(method);
|
|
31
|
+
return { ok: false, error: `Unexpected IPC method ${method}` };
|
|
32
|
+
},
|
|
33
|
+
exitFromIpcResult: (r: { error?: string }) => {
|
|
34
|
+
throw new Error(r.error ?? "IPC error");
|
|
35
|
+
},
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module("../../../runtime/routes/oauth-commands-routes.js", () => ({
|
|
39
|
+
handleRequest: async (args: unknown) => {
|
|
40
|
+
handleRequestCalls.push(args);
|
|
41
|
+
return handleRequestResult;
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
import { Command } from "commander";
|
|
46
|
+
|
|
47
|
+
import { applyCommandHelp } from "../../lib/cli-command-help.js";
|
|
48
|
+
import { oauthHelp } from "./index.help.js";
|
|
49
|
+
import { readBodyData, registerRequestCommand } from "./request.js";
|
|
50
|
+
|
|
51
|
+
let tempDir: string;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
ipcCalls.length = 0;
|
|
55
|
+
handleRequestCalls.length = 0;
|
|
56
|
+
process.exitCode = 0;
|
|
57
|
+
tempDir = mkdtempSync(join(tmpdir(), "oauth-request-"));
|
|
58
|
+
handleRequestResult = {
|
|
59
|
+
ok: true,
|
|
60
|
+
status: 200,
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
body: { hello: "world" },
|
|
63
|
+
account: "user@example.com",
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
69
|
+
process.exitCode = 0;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
async function runRequestCommand(args: string[]): Promise<{
|
|
73
|
+
stdout: Buffer;
|
|
74
|
+
exitCode: number;
|
|
75
|
+
}> {
|
|
76
|
+
const chunks: Buffer[] = [];
|
|
77
|
+
const originalWrite = process.stdout.write;
|
|
78
|
+
process.stdout.write = ((chunk: string | Uint8Array) => {
|
|
79
|
+
chunks.push(
|
|
80
|
+
typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk),
|
|
81
|
+
);
|
|
82
|
+
return true;
|
|
83
|
+
}) as typeof process.stdout.write;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const program = new Command();
|
|
87
|
+
program.exitOverride();
|
|
88
|
+
const oauth = program.command("oauth").description(oauthHelp.description);
|
|
89
|
+
applyCommandHelp(oauth, oauthHelp);
|
|
90
|
+
registerRequestCommand(oauth);
|
|
91
|
+
const request = oauth.commands.find(
|
|
92
|
+
(command) => command.name() === "request",
|
|
93
|
+
);
|
|
94
|
+
if (request) {
|
|
95
|
+
request.option("--json");
|
|
96
|
+
}
|
|
97
|
+
await program.parseAsync(["node", "test", "oauth", "request", ...args]);
|
|
98
|
+
} catch {
|
|
99
|
+
// Commander may throw under exitOverride for parse errors.
|
|
100
|
+
} finally {
|
|
101
|
+
process.stdout.write = originalWrite;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
stdout: Buffer.concat(chunks),
|
|
106
|
+
exitCode: Number(process.exitCode ?? 0),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("assistant oauth request", () => {
|
|
111
|
+
test("runs handleRequest in-process and does not call IPC", async () => {
|
|
112
|
+
const { stdout } = await runRequestCommand([
|
|
113
|
+
"--provider",
|
|
114
|
+
"google",
|
|
115
|
+
"-s",
|
|
116
|
+
"https://gmail.googleapis.com/gmail/v1/users/me/messages",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(ipcCalls).toEqual([]);
|
|
120
|
+
expect(handleRequestCalls).toEqual([
|
|
121
|
+
{
|
|
122
|
+
body: {
|
|
123
|
+
provider: "google",
|
|
124
|
+
url: "https://gmail.googleapis.com/gmail/v1/users/me/messages",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
]);
|
|
128
|
+
expect(stdout.toString("utf8")).toContain("hello");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("oauth request body encoding", () => {
|
|
133
|
+
const MULTIPART_BODY = [
|
|
134
|
+
"--boundary",
|
|
135
|
+
"Content-Type: application/json; charset=UTF-8",
|
|
136
|
+
"",
|
|
137
|
+
'{"name":"Sheet"}',
|
|
138
|
+
"--boundary--",
|
|
139
|
+
"",
|
|
140
|
+
].join("\r\n");
|
|
141
|
+
|
|
142
|
+
const MULTIPART_HEADERS = {
|
|
143
|
+
"Content-Type": "multipart/related; boundary=boundary",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
test("keeps a multipart body as the exact string it was given", async () => {
|
|
147
|
+
expect(readBodyData(MULTIPART_BODY, MULTIPART_HEADERS)).toBe(
|
|
148
|
+
MULTIPART_BODY,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("parses a JSON body into an object", async () => {
|
|
153
|
+
expect(
|
|
154
|
+
readBodyData('{"name":"Sheet"}', {
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
}),
|
|
157
|
+
).toEqual({ name: "Sheet" });
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("parses a JSON body when no Content-Type is given", async () => {
|
|
161
|
+
expect(readBodyData('{"name":"Sheet"}', {})).toEqual({
|
|
162
|
+
name: "Sheet",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("keeps a JSON-looking body raw under a non-JSON Content-Type", async () => {
|
|
167
|
+
expect(
|
|
168
|
+
readBodyData('{"name":"Sheet"}', { "content-type": "text/plain" }),
|
|
169
|
+
).toBe('{"name":"Sheet"}');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("keeps unparseable text raw when no Content-Type is given", async () => {
|
|
173
|
+
expect(readBodyData("not json at all", {})).toBe("not json at all");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("reads a @file body under the caller's Content-Type", async () => {
|
|
177
|
+
const filePath = join(tempDir, "upload.txt");
|
|
178
|
+
writeFileSync(filePath, MULTIPART_BODY, "utf-8");
|
|
179
|
+
|
|
180
|
+
expect(readBodyData(`@${filePath}`, MULTIPART_HEADERS)).toBe(
|
|
181
|
+
MULTIPART_BODY,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("forwards a multipart body to the route handler as a string", async () => {
|
|
186
|
+
const { exitCode } = await runRequestCommand([
|
|
187
|
+
"--provider",
|
|
188
|
+
"google",
|
|
189
|
+
"-s",
|
|
190
|
+
"-X",
|
|
191
|
+
"POST",
|
|
192
|
+
"-H",
|
|
193
|
+
"Content-Type: multipart/related; boundary=boundary",
|
|
194
|
+
"-d",
|
|
195
|
+
MULTIPART_BODY,
|
|
196
|
+
"https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart",
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
expect(exitCode).toBe(0);
|
|
200
|
+
expect(handleRequestCalls).toEqual([
|
|
201
|
+
{
|
|
202
|
+
body: {
|
|
203
|
+
provider: "google",
|
|
204
|
+
url: "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart",
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: {
|
|
207
|
+
"Content-Type": "multipart/related; boundary=boundary",
|
|
208
|
+
},
|
|
209
|
+
parsed_data: MULTIPART_BODY,
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("oauth request body output", () => {
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
handleRequestResult = {
|
|
219
|
+
ok: true,
|
|
220
|
+
status: 200,
|
|
221
|
+
headers: { "content-type": "application/octet-stream" },
|
|
222
|
+
body: PNG_MAGIC.toString("base64"),
|
|
223
|
+
bodyEncoding: "base64",
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("decodes a base64 envelope to raw bytes when writing a file", async () => {
|
|
228
|
+
const outputPath = join(tempDir, "drive.bin");
|
|
229
|
+
const { exitCode } = await runRequestCommand([
|
|
230
|
+
"--provider",
|
|
231
|
+
"google",
|
|
232
|
+
"-s",
|
|
233
|
+
"-o",
|
|
234
|
+
outputPath,
|
|
235
|
+
"https://www.googleapis.com/drive/v3/files/file-123?alt=media",
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
expect(exitCode).toBe(0);
|
|
239
|
+
expect(readFileSync(outputPath).equals(PNG_MAGIC)).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("writes raw binary bytes to stdout without an extra newline", async () => {
|
|
243
|
+
const { stdout, exitCode } = await runRequestCommand([
|
|
244
|
+
"--provider",
|
|
245
|
+
"google",
|
|
246
|
+
"-s",
|
|
247
|
+
"https://www.googleapis.com/drive/v3/files/file-123?alt=media",
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
expect(exitCode).toBe(0);
|
|
251
|
+
expect(stdout.equals(PNG_MAGIC)).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("keeps the encoded envelope in --json mode", async () => {
|
|
255
|
+
const { stdout, exitCode } = await runRequestCommand([
|
|
256
|
+
"--json",
|
|
257
|
+
"--provider",
|
|
258
|
+
"google",
|
|
259
|
+
"-s",
|
|
260
|
+
"https://www.googleapis.com/drive/v3/files/file-123?alt=media",
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
expect(exitCode).toBe(0);
|
|
264
|
+
const parsed = JSON.parse(stdout.toString("utf8")) as {
|
|
265
|
+
body: string;
|
|
266
|
+
bodyEncoding?: string;
|
|
267
|
+
};
|
|
268
|
+
expect(parsed.body).toBe(PNG_MAGIC.toString("base64"));
|
|
269
|
+
expect(parsed.bodyEncoding).toBe("base64");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("writes text bodies as UTF-8 and appends a newline on stdout", async () => {
|
|
273
|
+
handleRequestResult = {
|
|
274
|
+
ok: true,
|
|
275
|
+
status: 200,
|
|
276
|
+
headers: { "content-type": "text/plain" },
|
|
277
|
+
body: "hello café",
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const { stdout, exitCode } = await runRequestCommand([
|
|
281
|
+
"--provider",
|
|
282
|
+
"google",
|
|
283
|
+
"-s",
|
|
284
|
+
"https://www.googleapis.com/drive/v3/files/file-123",
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
expect(exitCode).toBe(0);
|
|
288
|
+
expect(stdout.toString("utf8")).toBe("hello café\n");
|
|
289
|
+
});
|
|
290
|
+
});
|