@xfey/tutti 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -20,14 +20,14 @@
20
20
  - `cli-package.mjs`:生成临时 staging `@xfey/tutti` package artifact 的脚本。
21
21
  - `prepare-package-assets.mjs`:把 Web build output 和内部 runtime package 复制到 staging package 所需位置。
22
22
  - `pack-dry-run.mjs`:构建 staging package 并执行 `npm pack --dry-run --json` 文件列表校验。
23
- - `smoke-packed-cli.mjs`:安装并验证 packed CLI tarball 的本地 smoke。
23
+ - `smoke-packed-cli.mjs`:安装并验证 packed CLI tarball 的本地 smoke,包括 CLI help / version、runtime deps 和 QR dependency
24
24
 
25
25
  ## 当前状态
26
26
 
27
- - 已具备 Fastify host shellCLI launch、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
27
+ - 已具备 Fastify host shell、后台 host manager CLI 套件、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
28
28
  - Host server 支持在显式 `webStatic.root` 或 packaged `web/` 存在时服务 Web 静态产物;`/api/*`、SSE、health 和 host-local control 不被 SPA fallback 吞掉。
29
29
  - Root `pack:dry-run` 会先清理 build output、重新构建,再复制 `apps/web/dist` 到 package-local `web/`,生成临时 staging `@xfey/tutti` artifact,并校验 npm pack 文件列表包含 CLI、migrations、prompts、Web index、bundled `@tutti/shared` / `@tutti/relay-client` 以及它们的外部 runtime dependencies,同时排除源码、source map、日志、SQLite、`.env`、`.tutti-dev` 与测试产物。
30
- - Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti launch --help`、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
30
+ - Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti launch --help` / `tutti --version`、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
31
31
  - `prompts/` 是所有模型-facing prompt、指令模板和 Codex 执行模板的统一落点;长 prompt 不内联在 TypeScript 中。
32
32
  - Host Project API 只信任 Relay tunnel metadata 注入的 `relay_session_context`;direct loopback 缺少可信 context 时不作为浏览器工作区入口。
33
33
  - 详细模块落点见 `src/README.md`;logger redaction、command idempotency、schema、provider 与 Codex 边界以 `docs/system/` 对应 canonical 文档为准。
@@ -43,7 +43,7 @@
43
43
  - packaged / production `tutti launch` 默认连接 `https://tutti.now`;`TUTTI_RELAY_URL` 可覆盖 Relay URL,本地开发 dev runner 默认使用 `http://127.0.0.1:4370`。
44
44
  - host 注册使用 machine-local `host_registration_secret`;该 secret 明文只可写入受限权限的 machine-local secret store,不写入目标项目 repo、Git local config 项目身份、SQLite 协作真相或日志,`binding.json` 只保存引用。
45
45
  - Relay registration 成功后只把 `relay_project_ref` 写回 machine-local binding;join URL 只在 host 本机终端或当前 host-local control 内存状态中展示,不写入 binding、runtime endpoint、SQLite 协作真相或日志。
46
- - 普通 `tutti launch` 的前台 stdout 只展示运行提示、setup URL join URL;Host HTTP request log 与 Control Plane / Run Pipeline runtime log 写入 `TUTTI_HOME/logs/host.log`,不写入目标项目 repo 或常规 CLI stdout。
46
+ - 普通 `tutti launch` 默认后台运行 host,CLI 完成页展示 join URL 和二维码;`tutti launch --foreground` 保留前台诊断模式。Host HTTP request log 与 Control Plane / Run Pipeline runtime log 写入 `TUTTI_HOME/logs/host.log`,不写入目标项目 repo 或常规 CLI stdout。
47
47
  - Reference 和 Skills 上传的最终写入由 Host Project API 完成;Host 只通过 Relay host-control resolve 获取短期 R2 URL 下载 staged object,不接收浏览器 base64 文件正文,也不持久化 presigned URL。
48
48
  - Host Project API command 的 browser session context 只信任 Relay tunnel metadata 中的 `relay_session_context`;route handler 不读取浏览器身份 header,也不接受 payload 内身份字段。
49
49
  - Fastify request log、debug log、SSE payload、activity event 和 run result 都必须经过 redaction,不得泄露 provider secret、host registration secret、host connection token、join token、cookie、host 绝对路径或 run workspace path。
@@ -1,7 +1,45 @@
1
+ export type CliCommand = {
2
+ kind: "help";
3
+ topic?: string;
4
+ } | {
5
+ kind: "version";
6
+ } | {
7
+ kind: "launch";
8
+ workspacePath: string;
9
+ yes: boolean;
10
+ foreground: boolean;
11
+ } | {
12
+ kind: "internal-host-run";
13
+ workspacePath: string;
14
+ yes: boolean;
15
+ } | {
16
+ kind: "ps";
17
+ manage: boolean;
18
+ } | {
19
+ kind: "stop";
20
+ workspacePath?: string;
21
+ } | {
22
+ kind: "invite";
23
+ workspacePath?: string;
24
+ } | {
25
+ kind: "logs";
26
+ workspacePath?: string;
27
+ tail: number;
28
+ } | {
29
+ kind: "provider-setup";
30
+ workspacePath?: string;
31
+ } | {
32
+ kind: "provider-status";
33
+ workspacePath?: string;
34
+ } | {
35
+ kind: "doctor";
36
+ workspacePath?: string;
37
+ };
1
38
  export type LaunchCliArgs = {
2
39
  command: "launch";
3
40
  workspacePath: string;
4
41
  yes: boolean;
5
42
  };
6
43
  export declare function parseLaunchCliArgs(argv: readonly string[], cwd?: string): LaunchCliArgs;
44
+ export declare function parseCliArgs(argv: readonly string[], cwd?: string): CliCommand;
7
45
  //# sourceMappingURL=args.d.ts.map
@@ -1,5 +1,11 @@
1
1
  import { resolve } from "node:path";
2
2
  import { LaunchError } from "./errors.js";
3
+ function isHelpArg(arg) {
4
+ return arg === "--help" || arg === "-h";
5
+ }
6
+ function hasHelpArg(args) {
7
+ return args.some((arg) => isHelpArg(arg));
8
+ }
3
9
  export function parseLaunchCliArgs(argv, cwd = process.cwd()) {
4
10
  const args = [...argv];
5
11
  const command = args.shift();
@@ -27,4 +33,166 @@ export function parseLaunchCliArgs(argv, cwd = process.cwd()) {
27
33
  yes,
28
34
  };
29
35
  }
36
+ function readOptionalWorkspace(args, cwd) {
37
+ let workspacePath;
38
+ for (const arg of args) {
39
+ if (arg.startsWith("--")) {
40
+ throw new LaunchError("git_bootstrap_failed", `Unsupported option: ${arg}`, "Run `tutti --help` to see supported commands.");
41
+ }
42
+ if (workspacePath !== undefined) {
43
+ throw new LaunchError("git_bootstrap_failed", "Only one workspace_path argument is supported", "Run `tutti --help` to see supported commands.");
44
+ }
45
+ workspacePath = resolve(cwd, arg);
46
+ }
47
+ return workspacePath;
48
+ }
49
+ export function parseCliArgs(argv, cwd = process.cwd()) {
50
+ const args = [...argv];
51
+ const command = args.shift();
52
+ if (command === undefined ||
53
+ command === "--help" ||
54
+ command === "-h" ||
55
+ command === "help") {
56
+ return { kind: "help", ...(args[0] === undefined ? {} : { topic: args[0] }) };
57
+ }
58
+ if (command === "--version" || command === "-v" || command === "version") {
59
+ return { kind: "version" };
60
+ }
61
+ if (command === "launch") {
62
+ let workspacePath;
63
+ let yes = false;
64
+ let foreground = false;
65
+ for (const arg of args) {
66
+ if (arg === "--yes") {
67
+ yes = true;
68
+ continue;
69
+ }
70
+ if (arg === "--foreground") {
71
+ foreground = true;
72
+ continue;
73
+ }
74
+ if (isHelpArg(arg)) {
75
+ return { kind: "help", topic: "launch" };
76
+ }
77
+ if (arg.startsWith("--")) {
78
+ throw new LaunchError("git_bootstrap_failed", `Unsupported option: ${arg}`, "Use `tutti launch --help` to see supported launch options.");
79
+ }
80
+ if (workspacePath !== undefined) {
81
+ throw new LaunchError("git_bootstrap_failed", "Only one workspace_path argument is supported", "Run `tutti launch [workspace_path] [--yes] [--foreground]`.");
82
+ }
83
+ workspacePath = resolve(cwd, arg);
84
+ }
85
+ return { kind: "launch", workspacePath: workspacePath ?? resolve(cwd, "."), yes, foreground };
86
+ }
87
+ if (command === "internal") {
88
+ const internalCommand = args.shift();
89
+ if (internalCommand !== "host-run") {
90
+ throw new LaunchError("git_bootstrap_failed", "Unsupported internal command", "Internal commands are managed by Tutti.");
91
+ }
92
+ let workspacePath;
93
+ let yes = false;
94
+ for (let index = 0; index < args.length; index += 1) {
95
+ const arg = args[index];
96
+ if (arg === "--yes") {
97
+ yes = true;
98
+ continue;
99
+ }
100
+ if (arg === "--workspace") {
101
+ const value = args[index + 1];
102
+ if (value === undefined) {
103
+ throw new LaunchError("git_bootstrap_failed", "--workspace requires a path", "Internal host-run commands are managed by Tutti.");
104
+ }
105
+ workspacePath = resolve(cwd, value);
106
+ index += 1;
107
+ continue;
108
+ }
109
+ throw new LaunchError("git_bootstrap_failed", `Unsupported internal option: ${arg ?? ""}`, "Internal commands are managed by Tutti.");
110
+ }
111
+ if (workspacePath === undefined) {
112
+ throw new LaunchError("git_bootstrap_failed", "Internal host-run requires --workspace", "Internal commands are managed by Tutti.");
113
+ }
114
+ return { kind: "internal-host-run", workspacePath, yes };
115
+ }
116
+ if (command === "ps") {
117
+ let manage = false;
118
+ for (const arg of args) {
119
+ if (arg === "--manage") {
120
+ manage = true;
121
+ continue;
122
+ }
123
+ if (isHelpArg(arg)) {
124
+ return { kind: "help", topic: "ps" };
125
+ }
126
+ throw new LaunchError("git_bootstrap_failed", `Unsupported option: ${arg}`, "Use `tutti ps --help` to see supported ps options.");
127
+ }
128
+ return { kind: "ps", manage };
129
+ }
130
+ if (command === "stop") {
131
+ if (hasHelpArg(args)) {
132
+ return { kind: "help", topic: "stop" };
133
+ }
134
+ const workspacePath = readOptionalWorkspace(args, cwd);
135
+ return { kind: "stop", ...(workspacePath === undefined ? {} : { workspacePath }) };
136
+ }
137
+ if (command === "invite") {
138
+ if (hasHelpArg(args)) {
139
+ return { kind: "help", topic: "invite" };
140
+ }
141
+ const workspacePath = readOptionalWorkspace(args, cwd);
142
+ return { kind: "invite", ...(workspacePath === undefined ? {} : { workspacePath }) };
143
+ }
144
+ if (command === "logs") {
145
+ if (hasHelpArg(args)) {
146
+ return { kind: "help", topic: "logs" };
147
+ }
148
+ let tail = 120;
149
+ const positional = [];
150
+ for (let index = 0; index < args.length; index += 1) {
151
+ const arg = args[index];
152
+ if (arg === "--tail") {
153
+ const value = args[index + 1];
154
+ const parsed = Number(value);
155
+ if (!Number.isInteger(parsed) || parsed <= 0) {
156
+ throw new LaunchError("git_bootstrap_failed", "--tail requires a positive integer", "Run `tutti logs [workspace_path] --tail 120`.");
157
+ }
158
+ tail = parsed;
159
+ index += 1;
160
+ }
161
+ else {
162
+ positional.push(arg ?? "");
163
+ }
164
+ }
165
+ const workspacePath = readOptionalWorkspace(positional, cwd);
166
+ return { kind: "logs", tail, ...(workspacePath === undefined ? {} : { workspacePath }) };
167
+ }
168
+ if (command === "provider") {
169
+ const providerCommand = args.shift();
170
+ if (providerCommand === undefined || isHelpArg(providerCommand)) {
171
+ return { kind: "help", topic: "provider" };
172
+ }
173
+ if (providerCommand === "setup") {
174
+ if (hasHelpArg(args)) {
175
+ return { kind: "help", topic: "provider" };
176
+ }
177
+ const workspacePath = readOptionalWorkspace(args, cwd);
178
+ return { kind: "provider-setup", ...(workspacePath === undefined ? {} : { workspacePath }) };
179
+ }
180
+ if (providerCommand === "status") {
181
+ if (hasHelpArg(args)) {
182
+ return { kind: "help", topic: "provider" };
183
+ }
184
+ const workspacePath = readOptionalWorkspace(args, cwd);
185
+ return { kind: "provider-status", ...(workspacePath === undefined ? {} : { workspacePath }) };
186
+ }
187
+ return { kind: "help", topic: "provider" };
188
+ }
189
+ if (command === "doctor") {
190
+ if (hasHelpArg(args)) {
191
+ return { kind: "help", topic: "doctor" };
192
+ }
193
+ const workspacePath = readOptionalWorkspace(args, cwd);
194
+ return { kind: "doctor", ...(workspacePath === undefined ? {} : { workspacePath }) };
195
+ }
196
+ throw new LaunchError("git_bootstrap_failed", `Unsupported command: ${command}`, "Run `tutti --help` to see supported commands.");
197
+ }
30
198
  //# sourceMappingURL=args.js.map
@@ -1,57 +1,184 @@
1
1
  #!/usr/bin/env node
2
- import { createInterface } from "node:readline/promises";
3
- import { formatLaunchError } from "./errors.js";
4
- import { formatLaunchLifecycleResult, startLaunchProject, waitForForegroundHostShutdown, } from "./host-lifecycle.js";
5
- import { parseLaunchCliArgs } from "./args.js";
2
+ import { formatCliError } from "./errors.js";
3
+ import { parseCliArgs } from "./args.js";
4
+ import { runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
5
+ import { resolveExistingProjectContext } from "./project-resolver.js";
6
+ import { runProviderSetupTui } from "./provider-tui.js";
7
+ import { runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, } from "./runtime-commands.js";
8
+ import { readCliVersion } from "./version.js";
6
9
  const HELP_TEXT = `Usage:
7
- tutti launch [workspace_path] [--yes]
10
+ tutti launch [workspace_path] [--yes] [--foreground]
11
+ tutti ps [--manage]
12
+ tutti invite [workspace_path]
13
+ tutti stop [workspace_path]
14
+ tutti logs [workspace_path] [--tail 120]
15
+ tutti provider setup [workspace_path]
16
+ tutti provider status [workspace_path]
17
+ tutti doctor [workspace_path]
8
18
 
9
19
  Options:
10
- --yes Accept non-interactive launch confirmations.
11
- -h, --help Show this help message.
12
- `;
13
- function isHelpRequest(argv) {
14
- return (argv.length === 0 ||
15
- argv.includes("--help") ||
16
- argv.includes("-h") ||
17
- (argv[0] === "launch" && (argv[1] === "--help" || argv[1] === "-h")));
18
- }
19
- async function confirmFromTty(request) {
20
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
21
- return false;
20
+ -h, --help Show help.
21
+ -v, --version Show version.
22
+
23
+ Notes:
24
+ launch starts the host in the background by default.
25
+ provider setup stores credentials in the machine-local Tutti provider store.
26
+ `;
27
+ const LAUNCH_HELP_TEXT = `Usage:
28
+ tutti launch [workspace_path] [--yes] [--foreground]
29
+
30
+ Starts or reconnects a Tutti project host.
31
+
32
+ Options:
33
+ --yes Accept non-interactive Git/bootstrap confirmations.
34
+ --foreground Keep the host attached to this terminal for diagnostics.
35
+ `;
36
+ const PROVIDER_HELP_TEXT = `Usage:
37
+ tutti provider setup [workspace_path]
38
+ tutti provider status [workspace_path]
39
+
40
+ Commands:
41
+ setup Configure or replace the OpenAI-compatible provider credential.
42
+ status Show the redacted provider status for a project.
43
+ `;
44
+ const PS_HELP_TEXT = `Usage:
45
+ tutti ps [--manage]
46
+
47
+ Options:
48
+ --manage Open the keyboard project manager.
49
+ `;
50
+ const INVITE_HELP_TEXT = `Usage:
51
+ tutti invite [workspace_path]
52
+
53
+ Rotates a fresh join link and renders it as a terminal QR code.
54
+ `;
55
+ const STOP_HELP_TEXT = `Usage:
56
+ tutti stop [workspace_path]
57
+
58
+ Stops the background Tutti host for a project.
59
+ `;
60
+ const LOGS_HELP_TEXT = `Usage:
61
+ tutti logs [workspace_path] [--tail 120]
62
+
63
+ Shows recent host log lines from the machine-local Tutti log file.
64
+ `;
65
+ const DOCTOR_HELP_TEXT = `Usage:
66
+ tutti doctor [workspace_path]
67
+
68
+ Prints local runtime, project, provider, and host diagnostics.
69
+ `;
70
+ function helpForTopic(topic) {
71
+ if (topic === "launch") {
72
+ return LAUNCH_HELP_TEXT;
73
+ }
74
+ if (topic === "provider") {
75
+ return PROVIDER_HELP_TEXT;
76
+ }
77
+ if (topic === "ps") {
78
+ return PS_HELP_TEXT;
79
+ }
80
+ if (topic === "invite") {
81
+ return INVITE_HELP_TEXT;
82
+ }
83
+ if (topic === "stop") {
84
+ return STOP_HELP_TEXT;
22
85
  }
23
- const readline = createInterface({
24
- input: process.stdin,
25
- output: process.stdout,
86
+ if (topic === "logs") {
87
+ return LOGS_HELP_TEXT;
88
+ }
89
+ if (topic === "doctor") {
90
+ return DOCTOR_HELP_TEXT;
91
+ }
92
+ return HELP_TEXT;
93
+ }
94
+ async function runProviderSetupCommand(workspacePath) {
95
+ const project = resolveExistingProjectContext({
96
+ ...(workspacePath === undefined ? {} : { workspacePath }),
97
+ });
98
+ const result = await runProviderSetupTui({
99
+ projectName: project.display_name,
100
+ tuttiHome: project.tutti_home,
101
+ projectId: project.project_id,
102
+ initialBaseUrl: project.provider_base_url ?? "https://api.openai.com/v1",
26
103
  });
104
+ process.stdout.write([
105
+ "",
106
+ "Provider configured.",
107
+ `Base URL: ${result.base_url}`,
108
+ `Model: ${result.default_model}`,
109
+ `Key: ${result.redacted_key}`,
110
+ `Validated: ${result.validated_at}`,
111
+ ].join("\n"));
112
+ process.stdout.write("\n");
113
+ }
114
+ function runDoctorCommand(workspacePath) {
115
+ const checks = [];
116
+ checks.push(`Node: ${process.version}`);
117
+ checks.push(`Tutti: ${readCliVersion()}`);
27
118
  try {
28
- const answer = await readline.question(`${request.message}\n\nContinue? [y/N] `);
29
- return /^(y|yes)$/iu.test(answer.trim());
119
+ const project = resolveExistingProjectContext({
120
+ ...(workspacePath === undefined ? {} : { workspacePath }),
121
+ });
122
+ checks.push(`Project: ${project.display_name}`);
123
+ checks.push(`Project ID: ${project.project_id}`);
124
+ checks.push(`Provider: ${project.provider_config.status}`);
125
+ checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
30
126
  }
31
- finally {
32
- readline.close();
127
+ catch (error) {
128
+ checks.push(`Project: unavailable (${error instanceof Error ? error.message : "unknown"})`);
33
129
  }
130
+ process.stdout.write(`${checks.join("\n")}\n`);
34
131
  }
35
132
  try {
36
- const argv = process.argv.slice(2);
37
- if (isHelpRequest(argv)) {
38
- process.stdout.write(HELP_TEXT);
39
- }
40
- else {
41
- const args = parseLaunchCliArgs(argv);
42
- const result = await startLaunchProject({
43
- workspacePath: args.workspacePath,
44
- yes: args.yes,
45
- confirm: confirmFromTty,
46
- });
47
- process.stdout.write(`${formatLaunchLifecycleResult(result, { hyperlinks: process.stdout.isTTY })}\n`);
48
- if (result.kind === "hosting") {
49
- await waitForForegroundHostShutdown(result.host);
50
- }
133
+ const command = parseCliArgs(process.argv.slice(2));
134
+ switch (command.kind) {
135
+ case "help":
136
+ process.stdout.write(helpForTopic(command.topic));
137
+ break;
138
+ case "version":
139
+ process.stdout.write(`${readCliVersion()}\n`);
140
+ break;
141
+ case "launch":
142
+ if (command.foreground) {
143
+ await runForegroundLaunchCommand(command);
144
+ }
145
+ else {
146
+ await runBackgroundLaunchCommand(command);
147
+ }
148
+ break;
149
+ case "internal-host-run":
150
+ await runInternalHostRunCommand(command);
151
+ break;
152
+ case "ps":
153
+ if (command.manage) {
154
+ await runPsManageCommand();
155
+ }
156
+ else {
157
+ process.stdout.write(`${await runPsCommand()}\n`);
158
+ }
159
+ break;
160
+ case "stop":
161
+ process.stdout.write(`${await runStopCommand(command.workspacePath)}\n`);
162
+ break;
163
+ case "invite":
164
+ process.stdout.write(`${await runInviteCommand(command.workspacePath)}\n`);
165
+ break;
166
+ case "logs":
167
+ process.stdout.write(`${runLogsCommand(command.workspacePath, command.tail)}\n`);
168
+ break;
169
+ case "provider-setup":
170
+ await runProviderSetupCommand(command.workspacePath);
171
+ break;
172
+ case "provider-status":
173
+ process.stdout.write(`${runProviderStatusCommand(command.workspacePath)}\n`);
174
+ break;
175
+ case "doctor":
176
+ runDoctorCommand(command.workspacePath);
177
+ break;
51
178
  }
52
179
  }
53
180
  catch (error) {
54
- console.error(formatLaunchError(error));
181
+ console.error(formatCliError(error));
55
182
  process.exitCode = 1;
56
183
  }
57
184
  //# sourceMappingURL=cli.js.map
@@ -6,4 +6,5 @@ export declare class LaunchError extends Error {
6
6
  constructor(code: LaunchErrorCode, message: string, next: string, details?: Record<string, unknown>);
7
7
  }
8
8
  export declare function formatLaunchError(error: unknown): string;
9
+ export declare function formatCliError(error: unknown): string;
9
10
  //# sourceMappingURL=errors.d.ts.map
@@ -38,6 +38,28 @@ export function formatLaunchError(error) {
38
38
  "Next: Retry after checking the local debug logs.",
39
39
  ].join("\n");
40
40
  }
41
+ export function formatCliError(error) {
42
+ if (error instanceof LaunchError) {
43
+ const lines = ["Tutti command failed", "", `Code: ${error.code}`, `Reason: ${redactText(error.message)}`];
44
+ const detailLines = Object.entries(error.details)
45
+ .filter(([, value]) => value !== undefined)
46
+ .map(([key, value]) => `Detail ${key}: ${redactText(formatDetailValue(value))}`);
47
+ if (detailLines.length > 0) {
48
+ lines.push(...detailLines);
49
+ }
50
+ lines.push(`Next: ${redactText(error.next)}`);
51
+ return lines.join("\n");
52
+ }
53
+ const redactedError = redactError(error);
54
+ const reason = typeof redactedError === "string" ? redactedError : JSON.stringify(redactedError);
55
+ return [
56
+ "Tutti command failed",
57
+ "",
58
+ "Code: internal_error",
59
+ `Reason: ${reason ?? "Unknown error"}`,
60
+ "Next: Retry after checking the local debug logs.",
61
+ ].join("\n");
62
+ }
41
63
  function formatDetailValue(value) {
42
64
  if (Array.isArray(value)) {
43
65
  return value.map((entry) => formatDetailValue(entry)).join(", ");
@@ -298,6 +298,14 @@ export async function startForegroundHostServer(options) {
298
298
  return result.projection;
299
299
  },
300
300
  },
301
+ ...(options.refreshRelayJoinUrl === undefined
302
+ ? {}
303
+ : {
304
+ refreshInvite: async () => {
305
+ await options.refreshRelayJoinUrl?.();
306
+ return options.getLaunchStatus?.() ?? {};
307
+ },
308
+ }),
301
309
  ...(options.getLaunchStatus === undefined ? {} : { getLaunchStatus: options.getLaunchStatus }),
302
310
  };
303
311
  let app;
@@ -0,0 +1,15 @@
1
+ import type { LaunchConfirmationRequest } from "./git-bootstrap.js";
2
+ export declare function confirmFromTty(request: LaunchConfirmationRequest): Promise<boolean>;
3
+ export declare function runForegroundLaunchCommand(options: {
4
+ workspacePath: string;
5
+ yes: boolean;
6
+ }): Promise<void>;
7
+ export declare function runInternalHostRunCommand(options: {
8
+ workspacePath: string;
9
+ yes: boolean;
10
+ }): Promise<void>;
11
+ export declare function runBackgroundLaunchCommand(options: {
12
+ workspacePath: string;
13
+ yes: boolean;
14
+ }): Promise<void>;
15
+ //# sourceMappingURL=launch-command.d.ts.map
@@ -0,0 +1,118 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { basename } from "node:path";
3
+ import { formatLaunchLifecycleResult, startLaunchProject, waitForForegroundHostShutdown } from "./host-lifecycle.js";
4
+ import { prepareLaunchProject } from "./launch.js";
5
+ import { spawnDetachedHost, waitForManagedHostReady } from "./managed-host.js";
6
+ import { renderTerminalQr } from "./terminal-qr.js";
7
+ import { renderTuttiTerminalLogo } from "./terminal-logo.js";
8
+ import { runProviderSetupTui } from "./provider-tui.js";
9
+ import { LaunchError } from "./errors.js";
10
+ const CLEAR = "\u001B[2J\u001B[H";
11
+ export async function confirmFromTty(request) {
12
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
13
+ return false;
14
+ }
15
+ const readline = createInterface({
16
+ input: process.stdin,
17
+ output: process.stdout,
18
+ });
19
+ try {
20
+ const answer = await readline.question(`${request.message}\n\nContinue? [y/N] `);
21
+ return /^(y|yes)$/iu.test(answer.trim());
22
+ }
23
+ finally {
24
+ readline.close();
25
+ }
26
+ }
27
+ function projectName(preparation) {
28
+ return basename(preparation.workspace_root) || preparation.project_id;
29
+ }
30
+ async function waitForEnter() {
31
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
32
+ return;
33
+ }
34
+ const readline = createInterface({
35
+ input: process.stdin,
36
+ output: process.stdout,
37
+ });
38
+ try {
39
+ await readline.question("");
40
+ }
41
+ finally {
42
+ readline.close();
43
+ }
44
+ }
45
+ function renderCompletionPage(options) {
46
+ const isTty = process.stdout.isTTY === true;
47
+ return [
48
+ ...(isTty ? [CLEAR, renderTuttiTerminalLogo({ tty: true }), ""] : []),
49
+ "Tutti is running in the background.",
50
+ "",
51
+ `Project: ${options.projectName}`,
52
+ "",
53
+ `Join URL: ${options.joinUrl}`,
54
+ "",
55
+ renderTerminalQr(options.joinUrl),
56
+ "",
57
+ "Press Enter to close this page. The host will keep running in the background.",
58
+ ].join("\n");
59
+ }
60
+ async function ensureProviderConfigured(preparation) {
61
+ if (preparation.provider_status === "configured") {
62
+ return;
63
+ }
64
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
65
+ 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
+ }
67
+ await runProviderSetupTui({
68
+ projectName: projectName(preparation),
69
+ tuttiHome: preparation.tutti_home,
70
+ projectId: preparation.project_id,
71
+ initialBaseUrl: "https://api.openai.com/v1",
72
+ });
73
+ }
74
+ export async function runForegroundLaunchCommand(options) {
75
+ const result = await startLaunchProject({
76
+ workspacePath: options.workspacePath,
77
+ yes: options.yes,
78
+ confirm: confirmFromTty,
79
+ });
80
+ process.stdout.write(`${formatLaunchLifecycleResult(result, { hyperlinks: process.stdout.isTTY })}\n`);
81
+ if (result.kind === "hosting") {
82
+ await waitForForegroundHostShutdown(result.host);
83
+ }
84
+ }
85
+ export async function runInternalHostRunCommand(options) {
86
+ const result = await startLaunchProject({
87
+ workspacePath: options.workspacePath,
88
+ yes: options.yes,
89
+ });
90
+ if (result.kind === "hosting") {
91
+ await waitForForegroundHostShutdown(result.host);
92
+ }
93
+ }
94
+ export async function runBackgroundLaunchCommand(options) {
95
+ const preparation = await prepareLaunchProject({
96
+ workspacePath: options.workspacePath,
97
+ yes: options.yes,
98
+ confirm: confirmFromTty,
99
+ });
100
+ await ensureProviderConfigured(preparation);
101
+ spawnDetachedHost({
102
+ workspaceRoot: preparation.workspace_root,
103
+ });
104
+ const ready = await waitForManagedHostReady({
105
+ tuttiHome: preparation.tutti_home,
106
+ projectId: preparation.project_id,
107
+ workspaceRoot: preparation.workspace_root,
108
+ });
109
+ if (ready.join_url === undefined) {
110
+ 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
+ }
112
+ process.stdout.write(`${renderCompletionPage({
113
+ projectName: projectName(preparation),
114
+ joinUrl: ready.join_url,
115
+ })}\n`);
116
+ await waitForEnter();
117
+ }
118
+ //# sourceMappingURL=launch-command.js.map