@xfey/tutti 0.1.9 → 0.1.11
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/dist/server-shell/cli/cli.js +3 -5
- package/dist/server-shell/cli/errors.d.ts +2 -1
- package/dist/server-shell/cli/errors.js +61 -12
- package/dist/server-shell/cli/launch-command.js +1 -10
- package/dist/server-shell/cli/launch.js +0 -3
- package/dist/server-shell/cli/managed-host.js +3 -18
- package/dist/server-shell/cli/provider-tui.d.ts +19 -2
- package/dist/server-shell/cli/provider-tui.js +85 -58
- package/dist/server-shell/cli/runtime-commands.js +18 -6
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { formatCliError } from "./errors.js";
|
|
2
|
+
import { formatCliError, formatCliErrorReason } from "./errors.js";
|
|
3
3
|
import { parseCliArgs } from "./args.js";
|
|
4
4
|
import { runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
|
|
5
5
|
import { resolveExistingProjectContext } from "./project-resolver.js";
|
|
@@ -96,15 +96,12 @@ async function runProviderSetupCommand(workspacePath) {
|
|
|
96
96
|
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
97
97
|
});
|
|
98
98
|
const result = await runProviderSetupTui({
|
|
99
|
-
projectName: project.display_name,
|
|
100
99
|
tuttiHome: project.tutti_home,
|
|
101
100
|
projectId: project.project_id,
|
|
102
|
-
initialBaseUrl: project.provider_base_url ?? "https://api.openai.com/v1",
|
|
103
101
|
});
|
|
104
102
|
process.stdout.write([
|
|
105
103
|
"",
|
|
106
104
|
"Provider configured.",
|
|
107
|
-
`Base URL: ${result.base_url}`,
|
|
108
105
|
`Model: ${result.default_model}`,
|
|
109
106
|
`Key: ${result.redacted_key}`,
|
|
110
107
|
`Validated: ${result.validated_at}`,
|
|
@@ -125,7 +122,8 @@ function runDoctorCommand(workspacePath) {
|
|
|
125
122
|
checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
|
|
126
123
|
}
|
|
127
124
|
catch (error) {
|
|
128
|
-
checks.push(
|
|
125
|
+
checks.push("Project: unavailable");
|
|
126
|
+
checks.push(`Project detail: ${formatCliErrorReason(error)}`);
|
|
129
127
|
}
|
|
130
128
|
process.stdout.write(`${checks.join("\n")}\n`);
|
|
131
129
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "provider_configuration_required" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable";
|
|
1
|
+
export type LaunchErrorCode = "unsafe_project_root" | "unsupported_tutti_schema" | "project_identity_conflict" | "git_bootstrap_failed" | "sensitive_file_detected" | "relay_unavailable" | "relay_registration_failed" | "host_not_running" | "provider_configuration_required" | "provider_setup_cancelled" | "provider_validation_failed" | "store_migration_failed" | "existing_server_unhealthy" | "takeover_rejected" | "takeover_failed" | "port_unavailable";
|
|
2
2
|
export declare class LaunchError extends Error {
|
|
3
3
|
readonly code: LaunchErrorCode;
|
|
4
4
|
readonly next: string;
|
|
@@ -7,4 +7,5 @@ export declare class LaunchError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
export declare function formatLaunchError(error: unknown): string;
|
|
9
9
|
export declare function formatCliError(error: unknown): string;
|
|
10
|
+
export declare function formatCliErrorReason(error: unknown): string;
|
|
10
11
|
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { redactError, redactText } from "@tutti/shared/utils";
|
|
1
|
+
import { redactAndTruncateText, redactError, redactText } from "@tutti/shared/utils";
|
|
2
2
|
export class LaunchError extends Error {
|
|
3
3
|
code;
|
|
4
4
|
next;
|
|
@@ -13,53 +13,102 @@ export class LaunchError extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
export function formatLaunchError(error) {
|
|
15
15
|
if (error instanceof LaunchError) {
|
|
16
|
+
if (error.code === "provider_setup_cancelled") {
|
|
17
|
+
return ["Tutti launch cancelled", "", `Reason: ${redactText(error.message)}`].join("\n");
|
|
18
|
+
}
|
|
16
19
|
const lines = [
|
|
17
20
|
"Tutti launch failed",
|
|
18
21
|
"",
|
|
19
22
|
`Code: ${error.code}`,
|
|
20
|
-
`Reason: ${
|
|
23
|
+
`Reason: ${formatBoundedText(error.message)}`,
|
|
21
24
|
];
|
|
22
25
|
const detailLines = Object.entries(error.details)
|
|
23
26
|
.filter(([, value]) => value !== undefined)
|
|
24
|
-
.map(([key, value]) => `Detail ${key}: ${
|
|
27
|
+
.map(([key, value]) => `Detail ${key}: ${formatBoundedDetail(value)}`);
|
|
25
28
|
if (detailLines.length > 0) {
|
|
26
29
|
lines.push(...detailLines);
|
|
27
30
|
}
|
|
28
31
|
lines.push(`Next: ${redactText(error.next)}`);
|
|
29
32
|
return lines.join("\n");
|
|
30
33
|
}
|
|
31
|
-
const redactedError = redactError(error);
|
|
32
|
-
const reason = typeof redactedError === "string" ? redactedError : JSON.stringify(redactedError);
|
|
33
34
|
return [
|
|
34
35
|
"Tutti launch failed",
|
|
35
36
|
"",
|
|
36
37
|
"Code: internal_error",
|
|
37
|
-
`Reason: ${
|
|
38
|
+
`Reason: ${formatUnknownErrorReason(error)}`,
|
|
38
39
|
"Next: Retry after checking the local debug logs.",
|
|
39
40
|
].join("\n");
|
|
40
41
|
}
|
|
41
42
|
export function formatCliError(error) {
|
|
42
43
|
if (error instanceof LaunchError) {
|
|
43
|
-
|
|
44
|
+
if (error.code === "provider_setup_cancelled") {
|
|
45
|
+
return ["Tutti command cancelled", "", `Reason: ${redactText(error.message)}`].join("\n");
|
|
46
|
+
}
|
|
47
|
+
const lines = ["Tutti command failed", "", `Code: ${error.code}`, `Reason: ${formatBoundedText(error.message)}`];
|
|
44
48
|
const detailLines = Object.entries(error.details)
|
|
45
49
|
.filter(([, value]) => value !== undefined)
|
|
46
|
-
.map(([key, value]) => `Detail ${key}: ${
|
|
50
|
+
.map(([key, value]) => `Detail ${key}: ${formatBoundedDetail(value)}`);
|
|
47
51
|
if (detailLines.length > 0) {
|
|
48
52
|
lines.push(...detailLines);
|
|
49
53
|
}
|
|
50
54
|
lines.push(`Next: ${redactText(error.next)}`);
|
|
51
55
|
return lines.join("\n");
|
|
52
56
|
}
|
|
53
|
-
const redactedError = redactError(error);
|
|
54
|
-
const reason = typeof redactedError === "string" ? redactedError : JSON.stringify(redactedError);
|
|
55
57
|
return [
|
|
56
58
|
"Tutti command failed",
|
|
57
59
|
"",
|
|
58
60
|
"Code: internal_error",
|
|
59
|
-
`Reason: ${
|
|
61
|
+
`Reason: ${formatUnknownErrorReason(error)}`,
|
|
60
62
|
"Next: Retry after checking the local debug logs.",
|
|
61
63
|
].join("\n");
|
|
62
64
|
}
|
|
65
|
+
const MAX_REASON_LENGTH = 320;
|
|
66
|
+
const MAX_DETAIL_LENGTH = 720;
|
|
67
|
+
function formatBoundedText(value) {
|
|
68
|
+
return redactAndTruncateText(value, MAX_REASON_LENGTH);
|
|
69
|
+
}
|
|
70
|
+
function formatBoundedDetail(value) {
|
|
71
|
+
return redactAndTruncateText(formatDetailValue(value), MAX_DETAIL_LENGTH);
|
|
72
|
+
}
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
export function formatCliErrorReason(error) {
|
|
77
|
+
const redactedError = redactError(error);
|
|
78
|
+
if (typeof redactedError === "string" && redactedError.trim() !== "") {
|
|
79
|
+
return formatBoundedText(redactedError);
|
|
80
|
+
}
|
|
81
|
+
if (isRecord(redactedError)) {
|
|
82
|
+
const message = redactedError.message;
|
|
83
|
+
if (typeof message === "string" && message.trim() !== "") {
|
|
84
|
+
return formatBoundedText(message);
|
|
85
|
+
}
|
|
86
|
+
const name = redactedError.name;
|
|
87
|
+
if (typeof name === "string" && name.trim() !== "") {
|
|
88
|
+
return formatBoundedText(name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return "Unexpected error.";
|
|
92
|
+
}
|
|
93
|
+
function formatUnknownErrorReason(error) {
|
|
94
|
+
return formatCliErrorReason(error);
|
|
95
|
+
}
|
|
96
|
+
function stripStackFields(value) {
|
|
97
|
+
if (Array.isArray(value)) {
|
|
98
|
+
return value.map((entry) => stripStackFields(entry));
|
|
99
|
+
}
|
|
100
|
+
if (isRecord(value)) {
|
|
101
|
+
const stripped = {};
|
|
102
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
103
|
+
if (key.toLowerCase() === "stack") {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
stripped[key] = stripStackFields(nested);
|
|
107
|
+
}
|
|
108
|
+
return stripped;
|
|
109
|
+
}
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
63
112
|
function formatDetailValue(value) {
|
|
64
113
|
if (Array.isArray(value)) {
|
|
65
114
|
return value.map((entry) => formatDetailValue(entry)).join(", ");
|
|
@@ -70,6 +119,6 @@ function formatDetailValue(value) {
|
|
|
70
119
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
71
120
|
return String(value);
|
|
72
121
|
}
|
|
73
|
-
return JSON.stringify(value) ?? "undefined";
|
|
122
|
+
return JSON.stringify(stripStackFields(value)) ?? "undefined";
|
|
74
123
|
}
|
|
75
124
|
//# sourceMappingURL=errors.js.map
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createInterface } from "node:readline/promises";
|
|
2
|
-
import { basename } from "node:path";
|
|
3
2
|
import { formatLaunchLifecycleResult, startLaunchProject, waitForForegroundHostShutdown } from "./host-lifecycle.js";
|
|
4
3
|
import { prepareLaunchProject } from "./launch.js";
|
|
5
4
|
import { spawnDetachedHost, waitForManagedHostReady } from "./managed-host.js";
|
|
@@ -24,9 +23,6 @@ export async function confirmFromTty(request) {
|
|
|
24
23
|
readline.close();
|
|
25
24
|
}
|
|
26
25
|
}
|
|
27
|
-
function projectName(preparation) {
|
|
28
|
-
return basename(preparation.workspace_root) || preparation.project_id;
|
|
29
|
-
}
|
|
30
26
|
async function waitForEnter() {
|
|
31
27
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
32
28
|
return;
|
|
@@ -48,13 +44,11 @@ function renderCompletionPage(options) {
|
|
|
48
44
|
...(isTty ? [CLEAR, renderTuttiTerminalLogo({ tty: true }), ""] : []),
|
|
49
45
|
"Tutti is running in the background.",
|
|
50
46
|
"",
|
|
51
|
-
`Project: ${options.projectName}`,
|
|
52
|
-
"",
|
|
53
47
|
`Join URL: ${options.joinUrl}`,
|
|
54
48
|
"",
|
|
55
49
|
renderTerminalQr(options.joinUrl),
|
|
56
50
|
"",
|
|
57
|
-
"
|
|
51
|
+
"[Enter] to continue",
|
|
58
52
|
].join("\n");
|
|
59
53
|
}
|
|
60
54
|
async function ensureProviderConfigured(preparation) {
|
|
@@ -65,10 +59,8 @@ async function ensureProviderConfigured(preparation) {
|
|
|
65
59
|
throw new LaunchError("provider_configuration_required", "Provider credentials are not configured for this project", "Run `tutti provider setup` from an interactive terminal, then run `tutti launch` again.");
|
|
66
60
|
}
|
|
67
61
|
await runProviderSetupTui({
|
|
68
|
-
projectName: projectName(preparation),
|
|
69
62
|
tuttiHome: preparation.tutti_home,
|
|
70
63
|
projectId: preparation.project_id,
|
|
71
|
-
initialBaseUrl: "https://api.openai.com/v1",
|
|
72
64
|
});
|
|
73
65
|
}
|
|
74
66
|
export async function runForegroundLaunchCommand(options) {
|
|
@@ -110,7 +102,6 @@ export async function runBackgroundLaunchCommand(options) {
|
|
|
110
102
|
throw new LaunchError("relay_registration_failed", "Host started but Relay did not return a visible join URL", "Run `tutti invite` to rotate a fresh invite link.");
|
|
111
103
|
}
|
|
112
104
|
process.stdout.write(`${renderCompletionPage({
|
|
113
|
-
projectName: projectName(preparation),
|
|
114
105
|
joinUrl: ready.join_url,
|
|
115
106
|
})}\n`);
|
|
116
107
|
await waitForEnter();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { createProjectId } from "@tutti/shared/ids";
|
|
3
|
-
import { redactText } from "@tutti/shared/utils";
|
|
4
3
|
import { readOpenAiProviderConfigProjection, resolveTuttiHome, } from "../../providers/openai/index.js";
|
|
5
4
|
import { LaunchError } from "./errors.js";
|
|
6
5
|
import { assertSafeProjectRoot, ensureGitBootstrap, } from "./git-bootstrap.js";
|
|
@@ -101,8 +100,6 @@ export function formatLaunchPreparation(result) {
|
|
|
101
100
|
"Tutti launch prerequisites are ready",
|
|
102
101
|
"",
|
|
103
102
|
"Status: prepared",
|
|
104
|
-
`Workspace: ${redactText(result.workspace_root)}`,
|
|
105
|
-
`Project: ${result.project_id}`,
|
|
106
103
|
`Branch: ${result.branch}`,
|
|
107
104
|
`Relay: ${result.relay_url}`,
|
|
108
105
|
`Provider: ${result.provider_status}`,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { resolve } from "node:path";
|
|
4
3
|
import { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
|
|
5
|
-
import {
|
|
4
|
+
import { LaunchError } from "./errors.js";
|
|
5
|
+
import { readMachineRuntimeEndpoint } from "./machine-local.js";
|
|
6
6
|
import { readHostLocalLaunchStatus, rotateHostLocalInvite } from "./local-control-client.js";
|
|
7
7
|
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
|
8
8
|
const READY_POLL_INTERVAL_MS = 250;
|
|
@@ -38,15 +38,6 @@ export function spawnDetachedHost(options) {
|
|
|
38
38
|
});
|
|
39
39
|
child.unref();
|
|
40
40
|
}
|
|
41
|
-
function readHostLogTail(tuttiHome, maxLines = 80) {
|
|
42
|
-
try {
|
|
43
|
-
const text = readFileSync(getHostLogFilePath(tuttiHome), "utf8");
|
|
44
|
-
return text.split(/\r?\n/u).filter(Boolean).slice(-maxLines).join("\n");
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
return "";
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
41
|
export async function waitForManagedHostReady(options) {
|
|
51
42
|
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
52
43
|
const probe = createRuntimeEndpointProbe(options.fetchImpl ?? fetch);
|
|
@@ -92,12 +83,6 @@ export async function waitForManagedHostReady(options) {
|
|
|
92
83
|
}
|
|
93
84
|
await delay(READY_POLL_INTERVAL_MS);
|
|
94
85
|
}
|
|
95
|
-
|
|
96
|
-
throw new Error([
|
|
97
|
-
`Tutti host did not become ready before timeout: ${lastReason}`,
|
|
98
|
-
tail.length === 0 ? "" : `Recent host log:\n${tail}`,
|
|
99
|
-
]
|
|
100
|
-
.filter(Boolean)
|
|
101
|
-
.join("\n"));
|
|
86
|
+
throw new LaunchError("existing_server_unhealthy", `Tutti host did not become ready before timeout: ${lastReason}`, "Run `tutti logs` for details, then retry `tutti launch`.");
|
|
102
87
|
}
|
|
103
88
|
//# sourceMappingURL=managed-host.js.map
|
|
@@ -1,16 +1,33 @@
|
|
|
1
|
+
import { type ConfigureProjectOpenAiProviderResult } from "../../providers/openai/index.js";
|
|
1
2
|
import type { ProjectId } from "@tutti/shared/ids";
|
|
3
|
+
type ProviderSetupLogEvent = {
|
|
4
|
+
ts: string;
|
|
5
|
+
level: "error";
|
|
6
|
+
scope: "provider_setup";
|
|
7
|
+
event: "provider_validation_failed" | "provider_validation_exception";
|
|
8
|
+
project_id: ProjectId;
|
|
9
|
+
reason?: string;
|
|
10
|
+
retryable?: boolean;
|
|
11
|
+
diagnostic?: unknown;
|
|
12
|
+
error?: unknown;
|
|
13
|
+
};
|
|
2
14
|
export type ProviderSetupResult = {
|
|
3
15
|
base_url: string;
|
|
4
16
|
redacted_key: string;
|
|
5
17
|
default_model: string;
|
|
6
18
|
validated_at: string;
|
|
7
19
|
};
|
|
20
|
+
export declare function providerValidationFailureMessage(result: ConfigureProjectOpenAiProviderResult): string;
|
|
21
|
+
export declare function writeProviderSetupValidationLog(options: {
|
|
22
|
+
tuttiHome: string;
|
|
23
|
+
event: Omit<ProviderSetupLogEvent, "ts" | "level" | "scope">;
|
|
24
|
+
now?: () => Date;
|
|
25
|
+
}): boolean;
|
|
8
26
|
export declare function runProviderSetupTui(options: {
|
|
9
|
-
projectName: string;
|
|
10
27
|
tuttiHome: string;
|
|
11
28
|
projectId: ProjectId;
|
|
12
|
-
initialBaseUrl?: string;
|
|
13
29
|
stdin?: NodeJS.ReadStream;
|
|
14
30
|
stdout?: NodeJS.WriteStream;
|
|
15
31
|
}): Promise<ProviderSetupResult>;
|
|
32
|
+
export {};
|
|
16
33
|
//# sourceMappingURL=provider-tui.d.ts.map
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { emitKeypressEvents } from "node:readline";
|
|
2
2
|
import { redactError } from "@tutti/shared/utils";
|
|
3
3
|
import { configureProjectOpenAiProvider, DEFAULT_OPENAI_MODEL, } from "../../providers/openai/index.js";
|
|
4
|
+
import { LaunchError } from "./errors.js";
|
|
5
|
+
import { appendHostLogLine, getHostLogFilePath } from "./machine-local.js";
|
|
4
6
|
import { renderTuttiTerminalLogo } from "./terminal-logo.js";
|
|
5
|
-
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
6
7
|
const HIDE_CURSOR = "\u001B[?25l";
|
|
7
8
|
const SHOW_CURSOR = "\u001B[?25h";
|
|
9
|
+
const BLINK_ON = "\u001B[5m";
|
|
10
|
+
const BLINK_OFF = "\u001B[25m";
|
|
8
11
|
const CLEAR = "\u001B[2J\u001B[H";
|
|
9
12
|
const DIVIDER = "----------------------------------------------------------------";
|
|
13
|
+
function providerSetupCancelled() {
|
|
14
|
+
return new LaunchError("provider_setup_cancelled", "Provider setup cancelled.", "Run the command again when you are ready.");
|
|
15
|
+
}
|
|
10
16
|
function fieldLine(options) {
|
|
11
17
|
const value = options.secret && options.value.length > 0 ? "*".repeat(options.value.length) : options.value;
|
|
12
|
-
return `> ${options.label}: ${value}
|
|
18
|
+
return `> ${options.label}: ${value}${BLINK_ON}_${BLINK_OFF}`;
|
|
13
19
|
}
|
|
14
20
|
function renderProviderField(state) {
|
|
15
21
|
if (state.step === "base-url") {
|
|
@@ -22,13 +28,11 @@ function renderProviderField(state) {
|
|
|
22
28
|
value: state.baseUrl,
|
|
23
29
|
}),
|
|
24
30
|
"",
|
|
25
|
-
"
|
|
31
|
+
"[Enter] to continue",
|
|
26
32
|
];
|
|
27
33
|
}
|
|
28
34
|
return [
|
|
29
35
|
"Step 2 of 2 - API Key",
|
|
30
|
-
`Base URL: ${state.baseUrl}`,
|
|
31
|
-
"",
|
|
32
36
|
"Enter the API key for this provider. It will be stored only in the machine-local Tutti credential store.",
|
|
33
37
|
"",
|
|
34
38
|
fieldLine({
|
|
@@ -37,7 +41,9 @@ function renderProviderField(state) {
|
|
|
37
41
|
secret: true,
|
|
38
42
|
}),
|
|
39
43
|
"",
|
|
40
|
-
|
|
44
|
+
state.error === undefined
|
|
45
|
+
? "[Enter] to validate [Esc] to edit Base URL"
|
|
46
|
+
: "[Enter] to retry [Esc] to edit Base URL",
|
|
41
47
|
];
|
|
42
48
|
}
|
|
43
49
|
function renderProviderForm(options) {
|
|
@@ -50,11 +56,8 @@ function renderProviderForm(options) {
|
|
|
50
56
|
DIVIDER,
|
|
51
57
|
"Tutti uses an OpenAI-compatible Responses API provider.",
|
|
52
58
|
"The base URL should expose POST /responses under the entered URL.",
|
|
53
|
-
`Default example: ${DEFAULT_BASE_URL}`,
|
|
54
59
|
DIVIDER,
|
|
55
60
|
"",
|
|
56
|
-
`Project: ${options.projectName}`,
|
|
57
|
-
"",
|
|
58
61
|
...renderProviderField(options.state),
|
|
59
62
|
"",
|
|
60
63
|
];
|
|
@@ -66,46 +69,58 @@ function renderProviderForm(options) {
|
|
|
66
69
|
}
|
|
67
70
|
return lines.join("\n");
|
|
68
71
|
}
|
|
69
|
-
function
|
|
72
|
+
export function providerValidationFailureMessage(result) {
|
|
70
73
|
if (result.kind === "configured") {
|
|
71
74
|
return "";
|
|
72
75
|
}
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
switch (result.reason) {
|
|
77
|
+
case "provider_auth_invalid":
|
|
78
|
+
return "API key was rejected.";
|
|
79
|
+
case "provider_model_unavailable":
|
|
80
|
+
return "Default model is unavailable for this provider.";
|
|
81
|
+
case "provider_quota_or_billing_required":
|
|
82
|
+
return "Provider quota or billing is not available.";
|
|
83
|
+
case "provider_rate_limited":
|
|
84
|
+
return "Provider is rate limited. Try again later.";
|
|
85
|
+
case "provider_network_error":
|
|
86
|
+
return "Could not reach a Responses API-compatible endpoint.";
|
|
87
|
+
}
|
|
75
88
|
}
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
89
|
+
function providerValidationExceptionMessage(logged) {
|
|
90
|
+
if (logged) {
|
|
91
|
+
return "Could not validate the provider connection. Details were written to the host log.";
|
|
92
|
+
}
|
|
93
|
+
return "Could not validate the provider connection. Host log could not be written.";
|
|
94
|
+
}
|
|
95
|
+
export function writeProviderSetupValidationLog(options) {
|
|
96
|
+
const logEvent = {
|
|
97
|
+
ts: (options.now ?? (() => new Date()))().toISOString(),
|
|
98
|
+
level: "error",
|
|
99
|
+
scope: "provider_setup",
|
|
100
|
+
...options.event,
|
|
101
|
+
};
|
|
102
|
+
const redactedLogEvent = {
|
|
103
|
+
...logEvent,
|
|
104
|
+
...(logEvent.error === undefined ? {} : { error: redactError(logEvent.error) }),
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
appendHostLogLine(getHostLogFilePath(options.tuttiHome), JSON.stringify(redactError(redactedLogEvent)));
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
97
113
|
}
|
|
98
114
|
async function readProviderForm(options) {
|
|
99
115
|
const state = {
|
|
100
116
|
step: options.initialStep ?? "base-url",
|
|
101
|
-
baseUrl: options.initialBaseUrl ??
|
|
117
|
+
baseUrl: options.initialBaseUrl ?? "",
|
|
102
118
|
apiKey: options.initialApiKey ?? "",
|
|
103
|
-
error:
|
|
119
|
+
error: options.initialError,
|
|
104
120
|
};
|
|
105
121
|
return await new Promise((resolve, reject) => {
|
|
106
122
|
const render = () => {
|
|
107
123
|
options.stdout.write(renderProviderForm({
|
|
108
|
-
projectName: options.projectName,
|
|
109
124
|
state,
|
|
110
125
|
tty: options.stdout.isTTY === true,
|
|
111
126
|
}));
|
|
@@ -120,7 +135,7 @@ async function readProviderForm(options) {
|
|
|
120
135
|
const onKeypress = (character, key) => {
|
|
121
136
|
if (key.ctrl === true && key.name === "c") {
|
|
122
137
|
cleanup();
|
|
123
|
-
reject(
|
|
138
|
+
reject(providerSetupCancelled());
|
|
124
139
|
return;
|
|
125
140
|
}
|
|
126
141
|
if (key.name === "escape") {
|
|
@@ -131,7 +146,7 @@ async function readProviderForm(options) {
|
|
|
131
146
|
return;
|
|
132
147
|
}
|
|
133
148
|
cleanup();
|
|
134
|
-
reject(
|
|
149
|
+
reject(providerSetupCancelled());
|
|
135
150
|
return;
|
|
136
151
|
}
|
|
137
152
|
if (key.name === "return") {
|
|
@@ -185,7 +200,7 @@ async function readProviderForm(options) {
|
|
|
185
200
|
render();
|
|
186
201
|
});
|
|
187
202
|
}
|
|
188
|
-
function renderChecking(
|
|
203
|
+
function renderChecking(frame, tty) {
|
|
189
204
|
const frames = ["|", "/", "-", "\\"];
|
|
190
205
|
const indicator = frames[frame % frames.length] ?? "|";
|
|
191
206
|
return [
|
|
@@ -194,8 +209,9 @@ function renderChecking(projectName, frame, tty) {
|
|
|
194
209
|
renderTuttiTerminalLogo({ tty }),
|
|
195
210
|
"",
|
|
196
211
|
"Provider setup",
|
|
197
|
-
|
|
198
|
-
|
|
212
|
+
DIVIDER,
|
|
213
|
+
"Validating the provider with the configured Responses API endpoint.",
|
|
214
|
+
DIVIDER,
|
|
199
215
|
"",
|
|
200
216
|
`${indicator} Checking provider connection with ${DEFAULT_OPENAI_MODEL}...`,
|
|
201
217
|
].join("\n");
|
|
@@ -204,17 +220,18 @@ export async function runProviderSetupTui(options) {
|
|
|
204
220
|
const stdin = options.stdin ?? process.stdin;
|
|
205
221
|
const stdout = options.stdout ?? process.stdout;
|
|
206
222
|
if (!stdin.isTTY || !stdout.isTTY) {
|
|
207
|
-
throw new
|
|
223
|
+
throw new LaunchError("provider_configuration_required", "Provider setup requires an interactive terminal.", "Run `tutti provider setup` from an interactive terminal.");
|
|
208
224
|
}
|
|
209
|
-
let initialBaseUrl
|
|
225
|
+
let initialBaseUrl;
|
|
210
226
|
let initialApiKey = "";
|
|
211
227
|
let initialStep = "base-url";
|
|
228
|
+
let initialError;
|
|
212
229
|
while (true) {
|
|
213
230
|
const input = await readProviderForm({
|
|
214
|
-
projectName: options.projectName,
|
|
215
231
|
...(initialBaseUrl === undefined ? {} : { initialBaseUrl }),
|
|
216
232
|
...(initialApiKey === "" ? {} : { initialApiKey }),
|
|
217
233
|
initialStep,
|
|
234
|
+
...(initialError === undefined ? {} : { initialError }),
|
|
218
235
|
stdin,
|
|
219
236
|
stdout,
|
|
220
237
|
});
|
|
@@ -223,11 +240,11 @@ export async function runProviderSetupTui(options) {
|
|
|
223
240
|
initialStep = "api-key";
|
|
224
241
|
let frame = 0;
|
|
225
242
|
const interval = setInterval(() => {
|
|
226
|
-
stdout.write(renderChecking(
|
|
243
|
+
stdout.write(renderChecking(frame, stdout.isTTY === true));
|
|
227
244
|
frame += 1;
|
|
228
245
|
}, 120);
|
|
229
246
|
try {
|
|
230
|
-
stdout.write(renderChecking(
|
|
247
|
+
stdout.write(renderChecking(frame, stdout.isTTY === true));
|
|
231
248
|
const result = await configureProjectOpenAiProvider({
|
|
232
249
|
tuttiHome: options.tuttiHome,
|
|
233
250
|
projectId: options.projectId,
|
|
@@ -244,23 +261,33 @@ export async function runProviderSetupTui(options) {
|
|
|
244
261
|
validated_at: result.projection.validated_at,
|
|
245
262
|
};
|
|
246
263
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
264
|
+
writeProviderSetupValidationLog({
|
|
265
|
+
tuttiHome: options.tuttiHome,
|
|
266
|
+
event: {
|
|
267
|
+
event: "provider_validation_failed",
|
|
268
|
+
project_id: options.projectId,
|
|
269
|
+
reason: result.reason,
|
|
270
|
+
retryable: result.retryable,
|
|
271
|
+
...(result.diagnostic === undefined ? {} : { diagnostic: result.diagnostic }),
|
|
254
272
|
},
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}));
|
|
258
|
-
await waitForKeypress(stdin);
|
|
273
|
+
});
|
|
274
|
+
initialError = providerValidationFailureMessage(result);
|
|
259
275
|
}
|
|
260
276
|
catch (error) {
|
|
261
277
|
clearInterval(interval);
|
|
262
278
|
stdout.write(SHOW_CURSOR);
|
|
263
|
-
|
|
279
|
+
if (error instanceof LaunchError) {
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
const logged = writeProviderSetupValidationLog({
|
|
283
|
+
tuttiHome: options.tuttiHome,
|
|
284
|
+
event: {
|
|
285
|
+
event: "provider_validation_exception",
|
|
286
|
+
project_id: options.projectId,
|
|
287
|
+
error,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
initialError = providerValidationExceptionMessage(logged);
|
|
264
291
|
}
|
|
265
292
|
}
|
|
266
293
|
}
|
|
@@ -6,6 +6,7 @@ import { redactText } from "@tutti/shared/utils";
|
|
|
6
6
|
import { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
|
|
7
7
|
import { getHostLogFilePath, getMachineRuntimeEndpointPath, getProjectLocalStoreRoot, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
|
|
8
8
|
import { readHostLocalLaunchStatus, readHostLocalProject, readHostLocalProviderConfig, requestHostLocalShutdown, rotateHostLocalInvite, } from "./local-control-client.js";
|
|
9
|
+
import { formatCliErrorReason, LaunchError } from "./errors.js";
|
|
9
10
|
import { resolveExistingProjectContext } from "./project-resolver.js";
|
|
10
11
|
import { renderTerminalQr } from "./terminal-qr.js";
|
|
11
12
|
import { resolveTuttiHome } from "../../providers/openai/index.js";
|
|
@@ -212,7 +213,7 @@ export async function runPsManageCommand(options = {}) {
|
|
|
212
213
|
message = `Invite refreshed for ${project.display_name}.`;
|
|
213
214
|
}
|
|
214
215
|
catch (error) {
|
|
215
|
-
message = error
|
|
216
|
+
message = formatCliErrorReason(error);
|
|
216
217
|
detail = undefined;
|
|
217
218
|
}
|
|
218
219
|
render();
|
|
@@ -232,7 +233,7 @@ export async function runPsManageCommand(options = {}) {
|
|
|
232
233
|
detail = undefined;
|
|
233
234
|
}
|
|
234
235
|
catch (error) {
|
|
235
|
-
message = error
|
|
236
|
+
message = formatCliErrorReason(error);
|
|
236
237
|
detail = undefined;
|
|
237
238
|
}
|
|
238
239
|
render();
|
|
@@ -254,19 +255,30 @@ export async function runStopCommand(workspacePath) {
|
|
|
254
255
|
if (endpoint === null) {
|
|
255
256
|
return "Tutti host is not running for this project.";
|
|
256
257
|
}
|
|
257
|
-
|
|
258
|
+
try {
|
|
259
|
+
await requestHostLocalShutdown({ endpoint });
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
throw new LaunchError("existing_server_unhealthy", `Could not stop the host: ${formatCliErrorReason(error)}`, "Run `tutti ps` to inspect the runtime state, or stop the host process manually.");
|
|
263
|
+
}
|
|
258
264
|
return `Stopped ${project.display_name}.`;
|
|
259
265
|
}
|
|
260
266
|
export async function runInviteCommand(workspacePath) {
|
|
261
267
|
const project = resolveProject(workspacePath);
|
|
262
268
|
const endpoint = project.runtime_endpoint;
|
|
263
269
|
if (endpoint === null) {
|
|
264
|
-
throw new
|
|
270
|
+
throw new LaunchError("host_not_running", "Tutti host is not running for this project.", "Run `tutti launch` first, then retry `tutti invite`.");
|
|
271
|
+
}
|
|
272
|
+
let status;
|
|
273
|
+
try {
|
|
274
|
+
status = await rotateHostLocalInvite({ endpoint });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw new LaunchError("existing_server_unhealthy", `Could not refresh the invite: ${formatCliErrorReason(error)}`, "Run `tutti launch` to restart or reconnect the host, then retry `tutti invite`.");
|
|
265
278
|
}
|
|
266
|
-
const status = await rotateHostLocalInvite({ endpoint });
|
|
267
279
|
const joinUrl = status.relay?.join_url;
|
|
268
280
|
if (joinUrl === undefined) {
|
|
269
|
-
throw new
|
|
281
|
+
throw new LaunchError("relay_registration_failed", "Relay did not return a visible join URL.", "Retry `tutti invite`; if it keeps failing, run `tutti launch` to reconnect the host.");
|
|
270
282
|
}
|
|
271
283
|
return [`Join URL: ${joinUrl}`, "", renderTerminalQr(joinUrl)].join("\n");
|
|
272
284
|
}
|