@xfey/tutti 0.1.5 → 0.1.7
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 +4 -4
- package/dist/providers/openai/app-server/read-only-procedure.js +3 -5
- package/dist/providers/openai/app-server/sandbox-policy.d.ts +18 -0
- package/dist/providers/openai/app-server/sandbox-policy.js +16 -0
- package/dist/providers/openai/app-server/smoke.js +3 -3
- package/dist/providers/openai/app-server/workspace-write-run.js +3 -5
- package/dist/providers/openai/codex-app-server.d.ts +0 -9
- package/dist/providers/openai/codex-app-server.js +0 -5
- package/dist/server-shell/cli/args.d.ts +42 -0
- package/dist/server-shell/cli/args.js +185 -0
- package/dist/server-shell/cli/cli.js +179 -40
- package/dist/server-shell/cli/errors.d.ts +1 -0
- package/dist/server-shell/cli/errors.js +22 -0
- package/dist/server-shell/cli/host-server-runtime.js +8 -0
- package/dist/server-shell/cli/launch-command.d.ts +19 -0
- package/dist/server-shell/cli/launch-command.js +127 -0
- package/dist/server-shell/cli/local-control-client.d.ts +30 -0
- package/dist/server-shell/cli/local-control-client.js +73 -0
- package/dist/server-shell/cli/managed-host.d.ts +19 -0
- package/dist/server-shell/cli/managed-host.js +103 -0
- package/dist/server-shell/cli/project-resolver.d.ts +19 -0
- package/dist/server-shell/cli/project-resolver.js +35 -0
- package/dist/server-shell/cli/provider-tui.d.ts +16 -0
- package/dist/server-shell/cli/provider-tui.js +226 -0
- package/dist/server-shell/cli/runtime-commands.d.ts +32 -0
- package/dist/server-shell/cli/runtime-commands.js +308 -0
- package/dist/server-shell/cli/terminal-qr.d.ts +2 -0
- package/dist/server-shell/cli/terminal-qr.js +11 -0
- package/dist/server-shell/cli/version.d.ts +2 -0
- package/dist/server-shell/cli/version.js +7 -0
- package/dist/server-shell/http/routes/local-control.d.ts +1 -0
- package/dist/server-shell/http/routes/local-control.js +13 -0
- package/package.json +2 -1
- package/dist/providers/openai/app-server/permission-profile.d.ts +0 -64
- package/dist/providers/openai/app-server/permission-profile.js +0 -67
|
@@ -1,57 +1,196 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { formatCliError } from "./errors.js";
|
|
3
|
+
import { parseCliArgs } from "./args.js";
|
|
4
|
+
import { runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, runRestartCommand, } 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 restart [workspace_path] [--yes]
|
|
15
|
+
tutti logs [workspace_path] [--tail 120]
|
|
16
|
+
tutti provider setup [workspace_path]
|
|
17
|
+
tutti provider status [workspace_path]
|
|
18
|
+
tutti doctor [workspace_path]
|
|
8
19
|
|
|
9
20
|
Options:
|
|
10
|
-
|
|
11
|
-
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
-h, --help Show help.
|
|
22
|
+
-v, --version Show version.
|
|
23
|
+
|
|
24
|
+
Notes:
|
|
25
|
+
launch starts the host in the background by default.
|
|
26
|
+
provider setup stores credentials in the machine-local Tutti provider store.
|
|
27
|
+
`;
|
|
28
|
+
const LAUNCH_HELP_TEXT = `Usage:
|
|
29
|
+
tutti launch [workspace_path] [--yes] [--foreground]
|
|
30
|
+
|
|
31
|
+
Starts or reconnects a Tutti project host.
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--yes Accept non-interactive Git/bootstrap confirmations.
|
|
35
|
+
--foreground Keep the host attached to this terminal for diagnostics.
|
|
36
|
+
`;
|
|
37
|
+
const PROVIDER_HELP_TEXT = `Usage:
|
|
38
|
+
tutti provider setup [workspace_path]
|
|
39
|
+
tutti provider status [workspace_path]
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
setup Configure or replace the OpenAI-compatible provider credential.
|
|
43
|
+
status Show the redacted provider status for a project.
|
|
44
|
+
`;
|
|
45
|
+
const PS_HELP_TEXT = `Usage:
|
|
46
|
+
tutti ps [--manage]
|
|
47
|
+
|
|
48
|
+
Options:
|
|
49
|
+
--manage Open the keyboard project manager.
|
|
50
|
+
`;
|
|
51
|
+
const INVITE_HELP_TEXT = `Usage:
|
|
52
|
+
tutti invite [workspace_path]
|
|
53
|
+
|
|
54
|
+
Rotates a fresh join link and renders it as a terminal QR code.
|
|
55
|
+
`;
|
|
56
|
+
const STOP_HELP_TEXT = `Usage:
|
|
57
|
+
tutti stop [workspace_path]
|
|
58
|
+
|
|
59
|
+
Stops the background Tutti host for a project.
|
|
60
|
+
`;
|
|
61
|
+
const RESTART_HELP_TEXT = `Usage:
|
|
62
|
+
tutti restart [workspace_path] [--yes]
|
|
63
|
+
|
|
64
|
+
Stops and relaunches the background Tutti host for a project.
|
|
65
|
+
`;
|
|
66
|
+
const LOGS_HELP_TEXT = `Usage:
|
|
67
|
+
tutti logs [workspace_path] [--tail 120]
|
|
68
|
+
|
|
69
|
+
Shows recent host log lines from the machine-local Tutti log file.
|
|
70
|
+
`;
|
|
71
|
+
const DOCTOR_HELP_TEXT = `Usage:
|
|
72
|
+
tutti doctor [workspace_path]
|
|
73
|
+
|
|
74
|
+
Prints local runtime, project, provider, and host diagnostics.
|
|
75
|
+
`;
|
|
76
|
+
function helpForTopic(topic) {
|
|
77
|
+
if (topic === "launch") {
|
|
78
|
+
return LAUNCH_HELP_TEXT;
|
|
79
|
+
}
|
|
80
|
+
if (topic === "provider") {
|
|
81
|
+
return PROVIDER_HELP_TEXT;
|
|
82
|
+
}
|
|
83
|
+
if (topic === "ps") {
|
|
84
|
+
return PS_HELP_TEXT;
|
|
85
|
+
}
|
|
86
|
+
if (topic === "invite") {
|
|
87
|
+
return INVITE_HELP_TEXT;
|
|
88
|
+
}
|
|
89
|
+
if (topic === "stop") {
|
|
90
|
+
return STOP_HELP_TEXT;
|
|
22
91
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
92
|
+
if (topic === "restart") {
|
|
93
|
+
return RESTART_HELP_TEXT;
|
|
94
|
+
}
|
|
95
|
+
if (topic === "logs") {
|
|
96
|
+
return LOGS_HELP_TEXT;
|
|
97
|
+
}
|
|
98
|
+
if (topic === "doctor") {
|
|
99
|
+
return DOCTOR_HELP_TEXT;
|
|
100
|
+
}
|
|
101
|
+
return HELP_TEXT;
|
|
102
|
+
}
|
|
103
|
+
async function runProviderSetupCommand(workspacePath) {
|
|
104
|
+
const project = resolveExistingProjectContext({
|
|
105
|
+
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
26
106
|
});
|
|
107
|
+
const result = await runProviderSetupTui({
|
|
108
|
+
projectName: project.display_name,
|
|
109
|
+
tuttiHome: project.tutti_home,
|
|
110
|
+
projectId: project.project_id,
|
|
111
|
+
initialBaseUrl: project.provider_base_url ?? "https://api.openai.com/v1",
|
|
112
|
+
});
|
|
113
|
+
process.stdout.write([
|
|
114
|
+
"",
|
|
115
|
+
"Provider configured.",
|
|
116
|
+
`Base URL: ${result.base_url}`,
|
|
117
|
+
`Model: ${result.default_model}`,
|
|
118
|
+
`Key: ${result.redacted_key}`,
|
|
119
|
+
`Validated: ${result.validated_at}`,
|
|
120
|
+
].join("\n"));
|
|
121
|
+
process.stdout.write("\n");
|
|
122
|
+
}
|
|
123
|
+
function runDoctorCommand(workspacePath) {
|
|
124
|
+
const checks = [];
|
|
125
|
+
checks.push(`Node: ${process.version}`);
|
|
126
|
+
checks.push(`Tutti: ${readCliVersion()}`);
|
|
27
127
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
128
|
+
const project = resolveExistingProjectContext({
|
|
129
|
+
...(workspacePath === undefined ? {} : { workspacePath }),
|
|
130
|
+
});
|
|
131
|
+
checks.push(`Project: ${project.display_name}`);
|
|
132
|
+
checks.push(`Project ID: ${project.project_id}`);
|
|
133
|
+
checks.push(`Provider: ${project.provider_config.status}`);
|
|
134
|
+
checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
|
|
30
135
|
}
|
|
31
|
-
|
|
32
|
-
|
|
136
|
+
catch (error) {
|
|
137
|
+
checks.push(`Project: unavailable (${error instanceof Error ? error.message : "unknown"})`);
|
|
33
138
|
}
|
|
139
|
+
process.stdout.write(`${checks.join("\n")}\n`);
|
|
34
140
|
}
|
|
35
141
|
try {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
142
|
+
const command = parseCliArgs(process.argv.slice(2));
|
|
143
|
+
switch (command.kind) {
|
|
144
|
+
case "help":
|
|
145
|
+
process.stdout.write(helpForTopic(command.topic));
|
|
146
|
+
break;
|
|
147
|
+
case "version":
|
|
148
|
+
process.stdout.write(`${readCliVersion()}\n`);
|
|
149
|
+
break;
|
|
150
|
+
case "launch":
|
|
151
|
+
if (command.foreground) {
|
|
152
|
+
await runForegroundLaunchCommand(command);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
await runBackgroundLaunchCommand(command);
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
case "internal-host-run":
|
|
159
|
+
await runInternalHostRunCommand(command);
|
|
160
|
+
break;
|
|
161
|
+
case "ps":
|
|
162
|
+
if (command.manage) {
|
|
163
|
+
await runPsManageCommand();
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
process.stdout.write(`${await runPsCommand()}\n`);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case "stop":
|
|
170
|
+
process.stdout.write(`${await runStopCommand(command.workspacePath)}\n`);
|
|
171
|
+
break;
|
|
172
|
+
case "restart":
|
|
173
|
+
await runRestartCommand(command);
|
|
174
|
+
break;
|
|
175
|
+
case "invite":
|
|
176
|
+
process.stdout.write(`${await runInviteCommand(command.workspacePath)}\n`);
|
|
177
|
+
break;
|
|
178
|
+
case "logs":
|
|
179
|
+
process.stdout.write(`${runLogsCommand(command.workspacePath, command.tail)}\n`);
|
|
180
|
+
break;
|
|
181
|
+
case "provider-setup":
|
|
182
|
+
await runProviderSetupCommand(command.workspacePath);
|
|
183
|
+
break;
|
|
184
|
+
case "provider-status":
|
|
185
|
+
process.stdout.write(`${runProviderStatusCommand(command.workspacePath)}\n`);
|
|
186
|
+
break;
|
|
187
|
+
case "doctor":
|
|
188
|
+
runDoctorCommand(command.workspacePath);
|
|
189
|
+
break;
|
|
51
190
|
}
|
|
52
191
|
}
|
|
53
192
|
catch (error) {
|
|
54
|
-
console.error(
|
|
193
|
+
console.error(formatCliError(error));
|
|
55
194
|
process.exitCode = 1;
|
|
56
195
|
}
|
|
57
196
|
//# 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,19 @@
|
|
|
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
|
+
export declare function runRestartCommand(options: {
|
|
16
|
+
workspacePath?: string;
|
|
17
|
+
yes: boolean;
|
|
18
|
+
}): Promise<void>;
|
|
19
|
+
//# sourceMappingURL=launch-command.d.ts.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { redactText } from "@tutti/shared/utils";
|
|
4
|
+
import { formatLaunchLifecycleResult, startLaunchProject, waitForForegroundHostShutdown } from "./host-lifecycle.js";
|
|
5
|
+
import { prepareLaunchProject } from "./launch.js";
|
|
6
|
+
import { spawnDetachedHost, waitForManagedHostReady } from "./managed-host.js";
|
|
7
|
+
import { renderTerminalQr } from "./terminal-qr.js";
|
|
8
|
+
import { runProviderSetupTui } from "./provider-tui.js";
|
|
9
|
+
import { LaunchError } from "./errors.js";
|
|
10
|
+
import { runStopCommand } from "./runtime-commands.js";
|
|
11
|
+
const CLEAR = "\u001B[2J\u001B[H";
|
|
12
|
+
export async function confirmFromTty(request) {
|
|
13
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const readline = createInterface({
|
|
17
|
+
input: process.stdin,
|
|
18
|
+
output: process.stdout,
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
const answer = await readline.question(`${request.message}\n\nContinue? [y/N] `);
|
|
22
|
+
return /^(y|yes)$/iu.test(answer.trim());
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
readline.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function projectName(preparation) {
|
|
29
|
+
return basename(preparation.workspace_root) || preparation.project_id;
|
|
30
|
+
}
|
|
31
|
+
async function waitForEnter() {
|
|
32
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const readline = createInterface({
|
|
36
|
+
input: process.stdin,
|
|
37
|
+
output: process.stdout,
|
|
38
|
+
});
|
|
39
|
+
try {
|
|
40
|
+
await readline.question("");
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
readline.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function renderCompletionPage(options) {
|
|
47
|
+
return [
|
|
48
|
+
...(process.stdout.isTTY ? [CLEAR] : []),
|
|
49
|
+
"Tutti is running in the background.",
|
|
50
|
+
"",
|
|
51
|
+
`Project: ${options.projectName}`,
|
|
52
|
+
`Workspace: ${redactText(options.workspaceRoot)}`,
|
|
53
|
+
"",
|
|
54
|
+
`Join URL: ${options.joinUrl}`,
|
|
55
|
+
"",
|
|
56
|
+
renderTerminalQr(options.joinUrl),
|
|
57
|
+
"",
|
|
58
|
+
"按下回车关闭页面,Host 会继续在后台运行。",
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
async function ensureProviderConfigured(preparation) {
|
|
62
|
+
if (preparation.provider_status === "configured") {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
66
|
+
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.");
|
|
67
|
+
}
|
|
68
|
+
await runProviderSetupTui({
|
|
69
|
+
projectName: projectName(preparation),
|
|
70
|
+
tuttiHome: preparation.tutti_home,
|
|
71
|
+
projectId: preparation.project_id,
|
|
72
|
+
initialBaseUrl: "https://api.openai.com/v1",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function runForegroundLaunchCommand(options) {
|
|
76
|
+
const result = await startLaunchProject({
|
|
77
|
+
workspacePath: options.workspacePath,
|
|
78
|
+
yes: options.yes,
|
|
79
|
+
confirm: confirmFromTty,
|
|
80
|
+
});
|
|
81
|
+
process.stdout.write(`${formatLaunchLifecycleResult(result, { hyperlinks: process.stdout.isTTY })}\n`);
|
|
82
|
+
if (result.kind === "hosting") {
|
|
83
|
+
await waitForForegroundHostShutdown(result.host);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function runInternalHostRunCommand(options) {
|
|
87
|
+
const result = await startLaunchProject({
|
|
88
|
+
workspacePath: options.workspacePath,
|
|
89
|
+
yes: options.yes,
|
|
90
|
+
});
|
|
91
|
+
if (result.kind === "hosting") {
|
|
92
|
+
await waitForForegroundHostShutdown(result.host);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function runBackgroundLaunchCommand(options) {
|
|
96
|
+
const preparation = await prepareLaunchProject({
|
|
97
|
+
workspacePath: options.workspacePath,
|
|
98
|
+
yes: options.yes,
|
|
99
|
+
confirm: confirmFromTty,
|
|
100
|
+
});
|
|
101
|
+
await ensureProviderConfigured(preparation);
|
|
102
|
+
spawnDetachedHost({
|
|
103
|
+
workspaceRoot: preparation.workspace_root,
|
|
104
|
+
});
|
|
105
|
+
const ready = await waitForManagedHostReady({
|
|
106
|
+
tuttiHome: preparation.tutti_home,
|
|
107
|
+
projectId: preparation.project_id,
|
|
108
|
+
workspaceRoot: preparation.workspace_root,
|
|
109
|
+
});
|
|
110
|
+
if (ready.join_url === undefined) {
|
|
111
|
+
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.");
|
|
112
|
+
}
|
|
113
|
+
process.stdout.write(`${renderCompletionPage({
|
|
114
|
+
projectName: projectName(preparation),
|
|
115
|
+
workspaceRoot: preparation.workspace_root,
|
|
116
|
+
joinUrl: ready.join_url,
|
|
117
|
+
})}\n`);
|
|
118
|
+
await waitForEnter();
|
|
119
|
+
}
|
|
120
|
+
export async function runRestartCommand(options) {
|
|
121
|
+
await runStopCommand(options.workspacePath).catch(() => undefined);
|
|
122
|
+
await runBackgroundLaunchCommand({
|
|
123
|
+
workspacePath: options.workspacePath ?? process.cwd(),
|
|
124
|
+
yes: options.yes,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=launch-command.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { HostLocalLaunchStatus, HostLocalProjectProjection, HostLocalProviderConfigBody } from "../http/routes/local-control.js";
|
|
2
|
+
import type { HostProviderConfigProjection } from "../http/routes/project-api/types.js";
|
|
3
|
+
import type { MachineRuntimeEndpointRecord } from "./machine-local.js";
|
|
4
|
+
export type LocalControlFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
5
|
+
export declare function readHostLocalLaunchStatus(options: {
|
|
6
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
7
|
+
fetchImpl?: LocalControlFetch;
|
|
8
|
+
}): Promise<HostLocalLaunchStatus>;
|
|
9
|
+
export declare function rotateHostLocalInvite(options: {
|
|
10
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
11
|
+
fetchImpl?: LocalControlFetch;
|
|
12
|
+
}): Promise<HostLocalLaunchStatus>;
|
|
13
|
+
export declare function readHostLocalProject(options: {
|
|
14
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
15
|
+
fetchImpl?: LocalControlFetch;
|
|
16
|
+
}): Promise<HostLocalProjectProjection>;
|
|
17
|
+
export declare function readHostLocalProviderConfig(options: {
|
|
18
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
19
|
+
fetchImpl?: LocalControlFetch;
|
|
20
|
+
}): Promise<HostProviderConfigProjection>;
|
|
21
|
+
export declare function writeHostLocalProviderConfig(options: {
|
|
22
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
23
|
+
body: HostLocalProviderConfigBody;
|
|
24
|
+
fetchImpl?: LocalControlFetch;
|
|
25
|
+
}): Promise<HostProviderConfigProjection>;
|
|
26
|
+
export declare function requestHostLocalShutdown(options: {
|
|
27
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
28
|
+
fetchImpl?: LocalControlFetch;
|
|
29
|
+
}): Promise<void>;
|
|
30
|
+
//# sourceMappingURL=local-control-client.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 2_000;
|
|
2
|
+
async function fetchWithTimeout(fetchImpl, url, init = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const timeout = setTimeout(() => {
|
|
5
|
+
controller.abort();
|
|
6
|
+
}, timeoutMs);
|
|
7
|
+
try {
|
|
8
|
+
return await fetchImpl(url, {
|
|
9
|
+
...init,
|
|
10
|
+
signal: controller.signal,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
clearTimeout(timeout);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
async function requestLocalJson(options) {
|
|
18
|
+
const response = await fetchWithTimeout(options.fetchImpl ?? fetch, new URL(options.path, options.endpoint.base_url), {
|
|
19
|
+
method: options.method ?? "GET",
|
|
20
|
+
headers: {
|
|
21
|
+
accept: "application/json",
|
|
22
|
+
authorization: `Bearer ${options.endpoint.token}`,
|
|
23
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
24
|
+
},
|
|
25
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
26
|
+
}, options.timeoutMs);
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`host-local ${options.path} returned HTTP ${response.status}`);
|
|
29
|
+
}
|
|
30
|
+
return (await response.json());
|
|
31
|
+
}
|
|
32
|
+
export async function readHostLocalLaunchStatus(options) {
|
|
33
|
+
return await requestLocalJson({
|
|
34
|
+
...options,
|
|
35
|
+
path: "/host-local/v1/launch-status",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function rotateHostLocalInvite(options) {
|
|
39
|
+
return await requestLocalJson({
|
|
40
|
+
...options,
|
|
41
|
+
method: "POST",
|
|
42
|
+
path: "/host-local/v1/invite/rotate",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export async function readHostLocalProject(options) {
|
|
46
|
+
return await requestLocalJson({
|
|
47
|
+
...options,
|
|
48
|
+
path: "/host-local/v1/project",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export async function readHostLocalProviderConfig(options) {
|
|
52
|
+
return await requestLocalJson({
|
|
53
|
+
...options,
|
|
54
|
+
path: "/host-local/v1/provider/config",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export async function writeHostLocalProviderConfig(options) {
|
|
58
|
+
return await requestLocalJson({
|
|
59
|
+
endpoint: options.endpoint,
|
|
60
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
61
|
+
method: "POST",
|
|
62
|
+
path: "/host-local/v1/provider/config",
|
|
63
|
+
body: options.body,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function requestHostLocalShutdown(options) {
|
|
67
|
+
await requestLocalJson({
|
|
68
|
+
...options,
|
|
69
|
+
method: "POST",
|
|
70
|
+
path: "/host-local/v1/shutdown",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=local-control-client.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type FetchLike } from "./host-runtime-endpoint.js";
|
|
2
|
+
import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
|
|
3
|
+
import type { ProjectId } from "@tutti/shared/ids";
|
|
4
|
+
export type ManagedHostReadyResult = {
|
|
5
|
+
endpoint: MachineRuntimeEndpointRecord;
|
|
6
|
+
join_url?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function spawnDetachedHost(options: {
|
|
9
|
+
workspaceRoot: string;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
}): void;
|
|
12
|
+
export declare function waitForManagedHostReady(options: {
|
|
13
|
+
tuttiHome: string;
|
|
14
|
+
projectId: ProjectId;
|
|
15
|
+
workspaceRoot: string;
|
|
16
|
+
fetchImpl?: FetchLike;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
}): Promise<ManagedHostReadyResult>;
|
|
19
|
+
//# sourceMappingURL=managed-host.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
|
|
5
|
+
import { getHostLogFilePath, readMachineRuntimeEndpoint, } from "./machine-local.js";
|
|
6
|
+
import { readHostLocalLaunchStatus, rotateHostLocalInvite } from "./local-control-client.js";
|
|
7
|
+
const DEFAULT_READY_TIMEOUT_MS = 60_000;
|
|
8
|
+
const READY_POLL_INTERVAL_MS = 250;
|
|
9
|
+
function delay(ms) {
|
|
10
|
+
return new Promise((resolveDelay) => {
|
|
11
|
+
setTimeout(resolveDelay, ms);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function currentCliEntrypoint() {
|
|
15
|
+
const entrypoint = process.argv[1];
|
|
16
|
+
if (entrypoint === undefined || entrypoint.trim() === "") {
|
|
17
|
+
throw new Error("Cannot locate the current Tutti CLI entrypoint.");
|
|
18
|
+
}
|
|
19
|
+
return entrypoint;
|
|
20
|
+
}
|
|
21
|
+
export function spawnDetachedHost(options) {
|
|
22
|
+
const child = spawn(process.execPath, [
|
|
23
|
+
...process.execArgv,
|
|
24
|
+
currentCliEntrypoint(),
|
|
25
|
+
"internal",
|
|
26
|
+
"host-run",
|
|
27
|
+
"--workspace",
|
|
28
|
+
resolve(options.workspaceRoot),
|
|
29
|
+
"--yes",
|
|
30
|
+
], {
|
|
31
|
+
cwd: options.workspaceRoot,
|
|
32
|
+
detached: true,
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
...(options.env ?? {}),
|
|
36
|
+
},
|
|
37
|
+
stdio: "ignore",
|
|
38
|
+
});
|
|
39
|
+
child.unref();
|
|
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
|
+
export async function waitForManagedHostReady(options) {
|
|
51
|
+
const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
52
|
+
const probe = createRuntimeEndpointProbe(options.fetchImpl ?? fetch);
|
|
53
|
+
let lastReason = "runtime endpoint was not written";
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
const endpoint = readMachineRuntimeEndpoint(options.tuttiHome, options.projectId);
|
|
56
|
+
if (endpoint === null) {
|
|
57
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (resolve(endpoint.workspace_root) !== resolve(options.workspaceRoot)) {
|
|
61
|
+
lastReason = "runtime endpoint points at a different workspace";
|
|
62
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const health = await probe(endpoint);
|
|
66
|
+
if (health.kind === "stale") {
|
|
67
|
+
lastReason = health.reason;
|
|
68
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
let status = await readHostLocalLaunchStatus({
|
|
73
|
+
endpoint,
|
|
74
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
75
|
+
});
|
|
76
|
+
if (status.relay?.join_url === undefined) {
|
|
77
|
+
status = await rotateHostLocalInvite({
|
|
78
|
+
endpoint,
|
|
79
|
+
...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (status.relay?.join_url !== undefined) {
|
|
83
|
+
return {
|
|
84
|
+
endpoint,
|
|
85
|
+
join_url: status.relay.join_url,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
lastReason = "host is running but Relay invite is not visible yet";
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
lastReason = error instanceof Error ? error.message : "host-local launch status failed";
|
|
92
|
+
}
|
|
93
|
+
await delay(READY_POLL_INTERVAL_MS);
|
|
94
|
+
}
|
|
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"));
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=managed-host.js.map
|