@xfey/tutti 0.1.10 → 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.
@@ -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";
@@ -102,7 +102,6 @@ async function runProviderSetupCommand(workspacePath) {
102
102
  process.stdout.write([
103
103
  "",
104
104
  "Provider configured.",
105
- `Base URL: ${result.base_url}`,
106
105
  `Model: ${result.default_model}`,
107
106
  `Key: ${result.redacted_key}`,
108
107
  `Validated: ${result.validated_at}`,
@@ -123,7 +122,8 @@ function runDoctorCommand(workspacePath) {
123
122
  checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
124
123
  }
125
124
  catch (error) {
126
- checks.push(`Project: unavailable (${error instanceof Error ? error.message : "unknown"})`);
125
+ checks.push("Project: unavailable");
126
+ checks.push(`Project detail: ${formatCliErrorReason(error)}`);
127
127
  }
128
128
  process.stdout.write(`${checks.join("\n")}\n`);
129
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_setup_cancelled" | "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;
@@ -20,24 +20,22 @@ export function formatLaunchError(error) {
20
20
  "Tutti launch failed",
21
21
  "",
22
22
  `Code: ${error.code}`,
23
- `Reason: ${redactText(error.message)}`,
23
+ `Reason: ${formatBoundedText(error.message)}`,
24
24
  ];
25
25
  const detailLines = Object.entries(error.details)
26
26
  .filter(([, value]) => value !== undefined)
27
- .map(([key, value]) => `Detail ${key}: ${redactText(formatDetailValue(value))}`);
27
+ .map(([key, value]) => `Detail ${key}: ${formatBoundedDetail(value)}`);
28
28
  if (detailLines.length > 0) {
29
29
  lines.push(...detailLines);
30
30
  }
31
31
  lines.push(`Next: ${redactText(error.next)}`);
32
32
  return lines.join("\n");
33
33
  }
34
- const redactedError = redactError(error);
35
- const reason = typeof redactedError === "string" ? redactedError : JSON.stringify(redactedError);
36
34
  return [
37
35
  "Tutti launch failed",
38
36
  "",
39
37
  "Code: internal_error",
40
- `Reason: ${reason ?? "Unknown error"}`,
38
+ `Reason: ${formatUnknownErrorReason(error)}`,
41
39
  "Next: Retry after checking the local debug logs.",
42
40
  ].join("\n");
43
41
  }
@@ -46,26 +44,71 @@ export function formatCliError(error) {
46
44
  if (error.code === "provider_setup_cancelled") {
47
45
  return ["Tutti command cancelled", "", `Reason: ${redactText(error.message)}`].join("\n");
48
46
  }
49
- const lines = ["Tutti command failed", "", `Code: ${error.code}`, `Reason: ${redactText(error.message)}`];
47
+ const lines = ["Tutti command failed", "", `Code: ${error.code}`, `Reason: ${formatBoundedText(error.message)}`];
50
48
  const detailLines = Object.entries(error.details)
51
49
  .filter(([, value]) => value !== undefined)
52
- .map(([key, value]) => `Detail ${key}: ${redactText(formatDetailValue(value))}`);
50
+ .map(([key, value]) => `Detail ${key}: ${formatBoundedDetail(value)}`);
53
51
  if (detailLines.length > 0) {
54
52
  lines.push(...detailLines);
55
53
  }
56
54
  lines.push(`Next: ${redactText(error.next)}`);
57
55
  return lines.join("\n");
58
56
  }
59
- const redactedError = redactError(error);
60
- const reason = typeof redactedError === "string" ? redactedError : JSON.stringify(redactedError);
61
57
  return [
62
58
  "Tutti command failed",
63
59
  "",
64
60
  "Code: internal_error",
65
- `Reason: ${reason ?? "Unknown error"}`,
61
+ `Reason: ${formatUnknownErrorReason(error)}`,
66
62
  "Next: Retry after checking the local debug logs.",
67
63
  ].join("\n");
68
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
+ }
69
112
  function formatDetailValue(value) {
70
113
  if (Array.isArray(value)) {
71
114
  return value.map((entry) => formatDetailValue(entry)).join(", ");
@@ -76,6 +119,6 @@ function formatDetailValue(value) {
76
119
  if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
77
120
  return String(value);
78
121
  }
79
- return JSON.stringify(value) ?? "undefined";
122
+ return JSON.stringify(stripStackFields(value)) ?? "undefined";
80
123
  }
81
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
- "Press Enter to close this page. The host will keep running in the background.",
51
+ "[Enter] to continue",
58
52
  ].join("\n");
59
53
  }
60
54
  async function ensureProviderConfigured(preparation) {
@@ -108,7 +102,6 @@ export async function runBackgroundLaunchCommand(options) {
108
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.");
109
103
  }
110
104
  process.stdout.write(`${renderCompletionPage({
111
- projectName: projectName(preparation),
112
105
  joinUrl: ready.join_url,
113
106
  })}\n`);
114
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 { getHostLogFilePath, readMachineRuntimeEndpoint, } from "./machine-local.js";
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
- const tail = readHostLogTail(options.tuttiHome);
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,14 +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
27
  tuttiHome: string;
10
28
  projectId: ProjectId;
11
29
  stdin?: NodeJS.ReadStream;
12
30
  stdout?: NodeJS.WriteStream;
13
31
  }): Promise<ProviderSetupResult>;
32
+ export {};
14
33
  //# sourceMappingURL=provider-tui.d.ts.map
@@ -2,6 +2,7 @@ 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
4
  import { LaunchError } from "./errors.js";
5
+ import { appendHostLogLine, getHostLogFilePath } from "./machine-local.js";
5
6
  import { renderTuttiTerminalLogo } from "./terminal-logo.js";
6
7
  const HIDE_CURSOR = "\u001B[?25l";
7
8
  const SHOW_CURSOR = "\u001B[?25h";
@@ -40,7 +41,9 @@ function renderProviderField(state) {
40
41
  secret: true,
41
42
  }),
42
43
  "",
43
- "[Enter] to validate [Esc] to edit Base URL",
44
+ state.error === undefined
45
+ ? "[Enter] to validate [Esc] to edit Base URL"
46
+ : "[Enter] to retry [Esc] to edit Base URL",
44
47
  ];
45
48
  }
46
49
  function renderProviderForm(options) {
@@ -66,41 +69,54 @@ function renderProviderForm(options) {
66
69
  }
67
70
  return lines.join("\n");
68
71
  }
69
- function validationFailureMessage(result) {
72
+ export function providerValidationFailureMessage(result) {
70
73
  if (result.kind === "configured") {
71
74
  return "";
72
75
  }
73
- const retry = result.retryable ? " Retry after the provider recovers." : "";
74
- return `${result.reason}.${retry}`;
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 waitForKeypress(stdin) {
77
- return new Promise((resolve, reject) => {
78
- const onKeypress = (_character, key) => {
79
- cleanup();
80
- if (key.ctrl === true && key.name === "c") {
81
- reject(providerSetupCancelled());
82
- return;
83
- }
84
- resolve();
85
- };
86
- const cleanup = () => {
87
- stdin.off("keypress", onKeypress);
88
- if (stdin.isTTY) {
89
- stdin.setRawMode(false);
90
- }
91
- };
92
- if (stdin.isTTY) {
93
- stdin.setRawMode(true);
94
- }
95
- stdin.once("keypress", onKeypress);
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
117
  baseUrl: options.initialBaseUrl ?? "",
102
118
  apiKey: options.initialApiKey ?? "",
103
- error: undefined,
119
+ error: options.initialError,
104
120
  };
105
121
  return await new Promise((resolve, reject) => {
106
122
  const render = () => {
@@ -204,16 +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 Error("Provider setup requires an interactive terminal.");
223
+ throw new LaunchError("provider_configuration_required", "Provider setup requires an interactive terminal.", "Run `tutti provider setup` from an interactive terminal.");
208
224
  }
209
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
231
  ...(initialBaseUrl === undefined ? {} : { initialBaseUrl }),
215
232
  ...(initialApiKey === "" ? {} : { initialApiKey }),
216
233
  initialStep,
234
+ ...(initialError === undefined ? {} : { initialError }),
217
235
  stdin,
218
236
  stdout,
219
237
  });
@@ -243,17 +261,17 @@ export async function runProviderSetupTui(options) {
243
261
  validated_at: result.projection.validated_at,
244
262
  };
245
263
  }
246
- stdout.write(renderProviderForm({
247
- state: {
248
- step: "api-key",
249
- baseUrl: input.baseUrl,
250
- apiKey: input.apiKey,
251
- error: validationFailureMessage(result),
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 }),
252
272
  },
253
- footer: "Press any key to edit and try again.",
254
- tty: stdout.isTTY === true,
255
- }));
256
- await waitForKeypress(stdin);
273
+ });
274
+ initialError = providerValidationFailureMessage(result);
257
275
  }
258
276
  catch (error) {
259
277
  clearInterval(interval);
@@ -261,7 +279,15 @@ export async function runProviderSetupTui(options) {
261
279
  if (error instanceof LaunchError) {
262
280
  throw error;
263
281
  }
264
- throw new Error(`Provider setup failed: ${String(redactError(error))}`, { cause: error });
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);
265
291
  }
266
292
  }
267
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 instanceof Error ? error.message : "Invite refresh failed.";
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 instanceof Error ? error.message : "Stop failed.";
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
- await requestHostLocalShutdown({ endpoint });
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 Error("Tutti host is not running for this project.");
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 Error("Relay did not return a visible join URL.");
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",