@xfey/tutti 0.1.33 → 0.1.35
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 +3 -3
- package/dist/server-shell/cli/args.d.ts +6 -0
- package/dist/server-shell/cli/args.js +30 -0
- package/dist/server-shell/cli/cli.js +118 -6
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/host-runtime-endpoint.d.ts +1 -0
- package/dist/server-shell/cli/host-runtime-endpoint.js +10 -5
- package/dist/server-shell/cli/launch.js +1 -0
- package/dist/server-shell/cli/machine-local.d.ts +3 -0
- package/dist/server-shell/cli/machine-local.js +24 -3
- package/dist/server-shell/cli/machine-project-inspector.d.ts +1 -0
- package/dist/server-shell/cli/machine-project-inspector.js +1 -0
- package/dist/server-shell/cli/managed-host.d.ts +1 -0
- package/dist/server-shell/cli/managed-host.js +6 -4
- package/dist/server-shell/cli/runtime-commands.d.ts +3 -2
- package/dist/server-shell/cli/runtime-commands.js +9 -1
- package/dist/server-shell/local-console/folder-picker.d.ts +0 -1
- package/dist/server-shell/local-console/folder-picker.js +2 -33
- package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
- package/dist/server-shell/local-console/invocation-context.js +56 -0
- package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
- package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
- package/dist/server-shell/local-console/managed-console.d.ts +38 -0
- package/dist/server-shell/local-console/managed-console.js +254 -36
- package/dist/server-shell/local-console/project-service.d.ts +16 -9
- package/dist/server-shell/local-console/project-service.js +152 -58
- package/dist/server-shell/local-console/runtime-endpoint.d.ts +15 -1
- package/dist/server-shell/local-console/runtime-endpoint.js +21 -5
- package/dist/server-shell/local-console/server.d.ts +12 -0
- package/dist/server-shell/local-console/server.js +152 -45
- package/dist/server-shell/local-console/session.d.ts +10 -15
- package/dist/server-shell/local-console/session.js +69 -17
- package/package.json +1 -1
- package/web/assets/index-DPuaTngs.js +29 -0
- package/web/assets/{index-BcZpeSaX.css → index-Dmbchapb.css} +1 -1
- package/web/index.html +2 -2
- package/web/assets/index-DKf4jz_E.js +0 -29
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
它负责:
|
|
8
8
|
|
|
9
9
|
- `tutti launch` 后的项目绑定、恢复和接管入口
|
|
10
|
-
- 裸 `tutti` 启动 /
|
|
10
|
+
- 裸 `tutti` 经 lifecycle lock 启动 / 复用的版本感知 loopback Local Console、独立 invocation context,以及既有项目 Host 编排
|
|
11
11
|
- Fastify API、SSE、packaged static Web 产物服务和 server 生命周期
|
|
12
12
|
- SQLite store、migrations、repository 与协作真相读写
|
|
13
13
|
- `control-plane`、scheduler、`procedure-engine`、`run-engine`、`workspace-ops` 与 OpenAI provider 接入
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
## 当前状态
|
|
27
27
|
|
|
28
|
-
- 已具备 Fastify host shell、后台 host manager CLI
|
|
28
|
+
- 已具备 Fastify host shell、后台 host manager CLI 套件、带 `service status / stop / restart`、卸载前 `shutdown`、并发启动锁、版本替换、独立 browser context、idle / package update cleanup 与私有日志的机器级 Local Console、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 接入。
|
|
29
29
|
- Host server 与 Local Console 支持在显式 `webStatic.root` 或 packaged `web/` 存在时服务 Web 静态产物;`/api/*`、SSE、health、host-local control 和 `/local-console/*` API 不被 SPA fallback 吞掉。
|
|
30
30
|
- 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` 与测试产物。
|
|
31
|
-
- Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti launch --help` / `tutti archive --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
|
+
- Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti service --help` / `tutti service status` / `tutti shutdown --help` / `tutti launch --help` / `tutti archive --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 存在。
|
|
32
32
|
- `prompts/` 是所有模型-facing prompt、指令模板和 Codex 执行模板的统一落点;长 prompt 不内联在 TypeScript 中。
|
|
33
33
|
- Host Project API 只信任 Relay tunnel metadata 注入的 `relay_session_context`;direct loopback 缺少可信 context 时不作为浏览器工作区入口。
|
|
34
34
|
- 详细模块落点见 `src/README.md`;logger redaction、command idempotency、schema、provider 与 Codex 边界以 `docs/system/` 对应 canonical 文档为准。
|
|
@@ -58,6 +58,36 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
58
58
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
59
59
|
return { kind: "version" };
|
|
60
60
|
}
|
|
61
|
+
if (command === "service") {
|
|
62
|
+
const action = args.shift();
|
|
63
|
+
if (action === undefined || isHelpArg(action)) {
|
|
64
|
+
return { kind: "help", topic: "service" };
|
|
65
|
+
}
|
|
66
|
+
if (args.length > 0) {
|
|
67
|
+
if (hasHelpArg(args)) {
|
|
68
|
+
return { kind: "help", topic: "service" };
|
|
69
|
+
}
|
|
70
|
+
throw new LaunchError("git_bootstrap_failed", `Unsupported service option: ${args[0] ?? ""}`, "Run `tutti service --help` to see supported service commands.");
|
|
71
|
+
}
|
|
72
|
+
if (action !== "status" && action !== "stop" && action !== "restart") {
|
|
73
|
+
throw new LaunchError("git_bootstrap_failed", `Unsupported service command: ${action}`, "Run `tutti service --help` to see supported service commands.");
|
|
74
|
+
}
|
|
75
|
+
return { kind: "service", action };
|
|
76
|
+
}
|
|
77
|
+
if (command === "shutdown") {
|
|
78
|
+
let yes = false;
|
|
79
|
+
for (const arg of args) {
|
|
80
|
+
if (arg === "--yes") {
|
|
81
|
+
yes = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (isHelpArg(arg)) {
|
|
85
|
+
return { kind: "help", topic: "shutdown" };
|
|
86
|
+
}
|
|
87
|
+
throw new LaunchError("git_bootstrap_failed", `Unsupported shutdown option: ${arg}`, "Run `tutti shutdown [--yes]`.");
|
|
88
|
+
}
|
|
89
|
+
return { kind: "shutdown", yes };
|
|
90
|
+
}
|
|
61
91
|
if (command === "launch") {
|
|
62
92
|
let target;
|
|
63
93
|
let yes = false;
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { formatCliError } from "./errors.js";
|
|
2
|
+
import { formatCliError, formatCliErrorReason, LaunchError } from "./errors.js";
|
|
3
3
|
import { parseCliArgs } from "./args.js";
|
|
4
4
|
import { confirmTextFromTty, runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
|
|
5
5
|
import { resolveExistingProjectContext } from "./project-resolver.js";
|
|
6
6
|
import { runProviderSetupTui } from "./provider-tui.js";
|
|
7
|
-
import { runArchiveCommand, runDoctorCommand, runForgetCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, } from "./runtime-commands.js";
|
|
7
|
+
import { runArchiveCommand, runDoctorCommand, runForgetCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, listRuntimeProjects, } from "./runtime-commands.js";
|
|
8
8
|
import { readCliVersion } from "./version.js";
|
|
9
|
-
import { ensureLocalConsole, openLocalConsoleBrowser, runLocalConsoleProcess, } from "../local-console/index.js";
|
|
9
|
+
import { ensureLocalConsole, openLocalConsoleBrowser, readLocalConsoleServiceStatus, restartLocalConsoleService, runLocalConsoleProcess, stopLocalConsoleService, } from "../local-console/index.js";
|
|
10
10
|
const HELP_TEXT = `Usage:
|
|
11
11
|
tutti
|
|
12
12
|
tutti launch [target] [--yes] [--foreground]
|
|
13
|
+
tutti service <status|stop|restart>
|
|
13
14
|
tutti ps [--manage] [--all]
|
|
14
15
|
tutti invite [target]
|
|
15
16
|
tutti stop [target]
|
|
@@ -19,6 +20,7 @@ const HELP_TEXT = `Usage:
|
|
|
19
20
|
Advanced and diagnostic commands:
|
|
20
21
|
tutti logs [target] [--tail 120]
|
|
21
22
|
tutti doctor [target]
|
|
23
|
+
tutti shutdown [--yes]
|
|
22
24
|
tutti archive <target> [--yes]
|
|
23
25
|
tutti forget <target> [--yes]
|
|
24
26
|
|
|
@@ -27,7 +29,8 @@ Options:
|
|
|
27
29
|
-v, --version Show version.
|
|
28
30
|
|
|
29
31
|
Notes:
|
|
30
|
-
tutti opens
|
|
32
|
+
tutti opens Tutti in your browser.
|
|
33
|
+
service manages only the local browser service, not project hosts.
|
|
31
34
|
launch starts the host in the background by default.
|
|
32
35
|
provider setup stores credentials in the machine-local Tutti provider store.
|
|
33
36
|
`;
|
|
@@ -48,6 +51,25 @@ Commands:
|
|
|
48
51
|
setup Configure or replace the OpenAI-compatible provider credential.
|
|
49
52
|
status Show the redacted provider status for a project.
|
|
50
53
|
`;
|
|
54
|
+
const SERVICE_HELP_TEXT = `Usage:
|
|
55
|
+
tutti service status
|
|
56
|
+
tutti service stop
|
|
57
|
+
tutti service restart
|
|
58
|
+
|
|
59
|
+
Commands:
|
|
60
|
+
status Show the local browser service version, process, and log path.
|
|
61
|
+
stop Stop only the local browser service; project hosts keep running.
|
|
62
|
+
restart Replace the local browser service with the current CLI version.
|
|
63
|
+
`;
|
|
64
|
+
const SHUTDOWN_HELP_TEXT = `Usage:
|
|
65
|
+
tutti shutdown [--yes]
|
|
66
|
+
|
|
67
|
+
Stops all project hosts on this machine and then stops the local browser service.
|
|
68
|
+
Use this before uninstalling Tutti.
|
|
69
|
+
|
|
70
|
+
Options:
|
|
71
|
+
--yes Confirm machine-wide shutdown in a non-interactive terminal.
|
|
72
|
+
`;
|
|
51
73
|
const PS_HELP_TEXT = `Usage:
|
|
52
74
|
tutti ps [--manage] [--all]
|
|
53
75
|
|
|
@@ -100,6 +122,12 @@ function helpForTopic(topic) {
|
|
|
100
122
|
if (topic === "provider") {
|
|
101
123
|
return PROVIDER_HELP_TEXT;
|
|
102
124
|
}
|
|
125
|
+
if (topic === "service") {
|
|
126
|
+
return SERVICE_HELP_TEXT;
|
|
127
|
+
}
|
|
128
|
+
if (topic === "shutdown") {
|
|
129
|
+
return SHUTDOWN_HELP_TEXT;
|
|
130
|
+
}
|
|
103
131
|
if (topic === "ps") {
|
|
104
132
|
return PS_HELP_TEXT;
|
|
105
133
|
}
|
|
@@ -159,10 +187,88 @@ async function runProviderSetupCommand(target) {
|
|
|
159
187
|
async function runLocalConsoleCommand() {
|
|
160
188
|
const console = await ensureLocalConsole();
|
|
161
189
|
if (openLocalConsoleBrowser(console.url)) {
|
|
162
|
-
process.stdout.write(`Tutti
|
|
190
|
+
process.stdout.write(`Tutti is ready at ${console.endpoint.base_url}/console\n`);
|
|
163
191
|
return;
|
|
164
192
|
}
|
|
165
|
-
process.stdout.write(["Open Tutti
|
|
193
|
+
process.stdout.write(["Open Tutti in a browser:", console.url, ""].join("\n"));
|
|
194
|
+
}
|
|
195
|
+
async function runServiceCommand(action) {
|
|
196
|
+
if (action === "status") {
|
|
197
|
+
const status = await readLocalConsoleServiceStatus();
|
|
198
|
+
if (status.status === "stopped") {
|
|
199
|
+
return [`Tutti service: stopped`, `Log: ${status.log_path}`].join("\n");
|
|
200
|
+
}
|
|
201
|
+
if (status.status === "stale") {
|
|
202
|
+
return [
|
|
203
|
+
"Tutti service: stale",
|
|
204
|
+
`PID: ${status.pid}`,
|
|
205
|
+
`Endpoint: ${status.base_url}`,
|
|
206
|
+
`Started: ${status.started_at}`,
|
|
207
|
+
`Log: ${status.log_path}`,
|
|
208
|
+
"Run `tutti service restart` to recover it.",
|
|
209
|
+
].join("\n");
|
|
210
|
+
}
|
|
211
|
+
return [
|
|
212
|
+
"Tutti service: running",
|
|
213
|
+
`Version: ${status.version}${status.update_required ? ` (current CLI: ${status.current_version})` : ""}`,
|
|
214
|
+
`PID: ${status.pid}`,
|
|
215
|
+
`Endpoint: ${status.base_url}`,
|
|
216
|
+
`Started: ${status.started_at}`,
|
|
217
|
+
`Log: ${status.log_path}`,
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
220
|
+
if (action === "stop") {
|
|
221
|
+
const result = await stopLocalConsoleService();
|
|
222
|
+
return [
|
|
223
|
+
result === "already_stopped" ? "Tutti service is already stopped." : "Tutti service stopped.",
|
|
224
|
+
"Project hosts were not affected. Run `tutti ps` to inspect them.",
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
const endpoint = await restartLocalConsoleService();
|
|
228
|
+
return [
|
|
229
|
+
"Tutti service restarted.",
|
|
230
|
+
`Version: ${endpoint.version}`,
|
|
231
|
+
`Endpoint: ${endpoint.base_url}`,
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
async function runShutdownCommand(yes) {
|
|
235
|
+
if (!yes) {
|
|
236
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
237
|
+
throw new LaunchError("shutdown_confirmation_required", "Machine-wide shutdown requires explicit confirmation", "Run `tutti shutdown --yes` after confirming that active project hosts may stop.");
|
|
238
|
+
}
|
|
239
|
+
const accepted = await confirmTextFromTty([
|
|
240
|
+
"Stop every Tutti project host on this machine and the local browser service?",
|
|
241
|
+
"Active collaborators and in-progress work may be interrupted.",
|
|
242
|
+
].join("\n"));
|
|
243
|
+
if (!accepted) {
|
|
244
|
+
throw new LaunchError("shutdown_rejected", "Machine-wide shutdown was not confirmed", "No Tutti process was intentionally stopped.");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const projects = await listRuntimeProjects({ all: true });
|
|
248
|
+
const failures = [];
|
|
249
|
+
let stoppedHosts = 0;
|
|
250
|
+
for (const project of projects) {
|
|
251
|
+
try {
|
|
252
|
+
const message = await runStopCommand(project.project_id);
|
|
253
|
+
if (!message.includes("not running")) {
|
|
254
|
+
stoppedHosts += 1;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
failures.push(`${project.project_id}: ${formatCliErrorReason(error)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const service = await stopLocalConsoleService();
|
|
262
|
+
if (failures.length > 0) {
|
|
263
|
+
throw new LaunchError("existing_server_unhealthy", `Tutti stopped ${stoppedHosts} project host(s), but ${failures.length} host(s) could not be stopped safely`, "Run `tutti ps --all` and inspect the remaining host processes before uninstalling.", { failures });
|
|
264
|
+
}
|
|
265
|
+
return [
|
|
266
|
+
`Stopped ${stoppedHosts} project host(s).`,
|
|
267
|
+
service === "already_stopped"
|
|
268
|
+
? "The Tutti browser service was already stopped."
|
|
269
|
+
: "Stopped the Tutti browser service.",
|
|
270
|
+
"Tutti is ready to uninstall.",
|
|
271
|
+
].join("\n");
|
|
166
272
|
}
|
|
167
273
|
try {
|
|
168
274
|
const command = parseCliArgs(process.argv.slice(2));
|
|
@@ -176,6 +282,12 @@ try {
|
|
|
176
282
|
case "version":
|
|
177
283
|
process.stdout.write(`${readCliVersion()}\n`);
|
|
178
284
|
break;
|
|
285
|
+
case "service":
|
|
286
|
+
process.stdout.write(`${await runServiceCommand(command.action)}\n`);
|
|
287
|
+
break;
|
|
288
|
+
case "shutdown":
|
|
289
|
+
process.stdout.write(`${await runShutdownCommand(command.yes)}\n`);
|
|
290
|
+
break;
|
|
179
291
|
case "launch":
|
|
180
292
|
if (command.foreground) {
|
|
181
293
|
await runForegroundLaunchCommand(command);
|
|
@@ -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" | "host_not_running" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "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" | "shutdown_confirmation_required" | "shutdown_rejected" | "archive_confirmation_required" | "archive_rejected" | "forget_confirmation_required" | "forget_rejected" | "project_target_ambiguous" | "project_target_not_found" | "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;
|
|
@@ -3,6 +3,7 @@ import type { MachineRuntimeEndpointRecord } from "./machine-local.js";
|
|
|
3
3
|
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
4
4
|
export type HostRuntimeEndpointProbeResult = {
|
|
5
5
|
kind: "alive";
|
|
6
|
+
version?: string;
|
|
6
7
|
readiness?: {
|
|
7
8
|
relay?: "not_connected" | "connected";
|
|
8
9
|
};
|
|
@@ -13,15 +13,19 @@ function parseHostHealthPayload(value) {
|
|
|
13
13
|
if (record.service !== "tutti-host" || record.status !== "ok") {
|
|
14
14
|
return null;
|
|
15
15
|
}
|
|
16
|
+
const version = typeof record.version === "string" ? record.version : undefined;
|
|
16
17
|
if (typeof record.readiness !== "object" ||
|
|
17
18
|
record.readiness === null ||
|
|
18
19
|
Array.isArray(record.readiness)) {
|
|
19
|
-
return {};
|
|
20
|
+
return version === undefined ? {} : { version };
|
|
20
21
|
}
|
|
21
22
|
const readiness = record.readiness;
|
|
22
|
-
return
|
|
23
|
-
? {
|
|
24
|
-
|
|
23
|
+
return {
|
|
24
|
+
...(version === undefined ? {} : { version }),
|
|
25
|
+
...(readiness.relay === "connected" || readiness.relay === "not_connected"
|
|
26
|
+
? { relay: readiness.relay }
|
|
27
|
+
: {}),
|
|
28
|
+
};
|
|
25
29
|
}
|
|
26
30
|
async function fetchWithTimeout(fetchImpl, url, init = {}, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
|
|
27
31
|
const controller = new AbortController();
|
|
@@ -55,7 +59,8 @@ export function createRuntimeEndpointProbe(fetchImpl = fetch) {
|
|
|
55
59
|
}
|
|
56
60
|
return {
|
|
57
61
|
kind: "alive",
|
|
58
|
-
...(readiness.
|
|
62
|
+
...(readiness.version === undefined ? {} : { version: readiness.version }),
|
|
63
|
+
...(readiness.relay === undefined ? {} : { readiness: { relay: readiness.relay } }),
|
|
59
64
|
};
|
|
60
65
|
}
|
|
61
66
|
catch (error) {
|
|
@@ -68,6 +68,7 @@ export async function prepareLaunchProject(options) {
|
|
|
68
68
|
tuttiHome,
|
|
69
69
|
projectId,
|
|
70
70
|
workspaceRoot,
|
|
71
|
+
relayUrl,
|
|
71
72
|
displayNameSnapshot: readProjectDisplayName(workspaceRoot) ?? (basename(workspaceRoot) || projectId),
|
|
72
73
|
};
|
|
73
74
|
if (options.allowWorkspaceRootTakeover !== undefined) {
|
|
@@ -5,7 +5,9 @@ export type MachineProjectBindingRecord = {
|
|
|
5
5
|
local_store_root: string;
|
|
6
6
|
display_name_snapshot?: string;
|
|
7
7
|
relay_project_ref?: RelayProjectRef;
|
|
8
|
+
relay_url?: string;
|
|
8
9
|
host_registration_secret_ref?: string;
|
|
10
|
+
created_at?: string;
|
|
9
11
|
updated_at: string;
|
|
10
12
|
};
|
|
11
13
|
export type HostRegistrationSecretFile = {
|
|
@@ -28,6 +30,7 @@ export type EnsureMachineProjectBindingOptions = {
|
|
|
28
30
|
projectId: ProjectId;
|
|
29
31
|
workspaceRoot: string;
|
|
30
32
|
displayNameSnapshot?: string;
|
|
33
|
+
relayUrl?: string;
|
|
31
34
|
allowWorkspaceRootTakeover?: boolean;
|
|
32
35
|
now?: () => Date;
|
|
33
36
|
};
|
|
@@ -78,7 +78,10 @@ export function readMachineProjectBinding(tuttiHome, projectId) {
|
|
|
78
78
|
(typeof raw.relay_project_ref !== "string" ||
|
|
79
79
|
!isPrefixedId(raw.relay_project_ref, ID_PREFIXES.relayProject))) ||
|
|
80
80
|
(raw.host_registration_secret_ref !== undefined &&
|
|
81
|
-
typeof raw.host_registration_secret_ref !== "string")
|
|
81
|
+
typeof raw.host_registration_secret_ref !== "string") ||
|
|
82
|
+
(raw.relay_url !== undefined &&
|
|
83
|
+
(typeof raw.relay_url !== "string" || raw.relay_url.trim() === "")) ||
|
|
84
|
+
(raw.created_at !== undefined && typeof raw.created_at !== "string")) {
|
|
82
85
|
throw new LaunchError("project_identity_conflict", "Machine-local project binding is invalid or conflicts with the Git config project identity", "Inspect or remove the project binding under TUTTI_HOME after confirming it is not needed.");
|
|
83
86
|
}
|
|
84
87
|
const binding = {
|
|
@@ -93,9 +96,15 @@ export function readMachineProjectBinding(tuttiHome, projectId) {
|
|
|
93
96
|
if (raw.relay_project_ref !== undefined) {
|
|
94
97
|
binding.relay_project_ref = raw.relay_project_ref;
|
|
95
98
|
}
|
|
99
|
+
if (raw.relay_url !== undefined) {
|
|
100
|
+
binding.relay_url = raw.relay_url;
|
|
101
|
+
}
|
|
96
102
|
if (raw.host_registration_secret_ref !== undefined) {
|
|
97
103
|
binding.host_registration_secret_ref = raw.host_registration_secret_ref;
|
|
98
104
|
}
|
|
105
|
+
if (raw.created_at !== undefined) {
|
|
106
|
+
binding.created_at = raw.created_at;
|
|
107
|
+
}
|
|
99
108
|
return binding;
|
|
100
109
|
}
|
|
101
110
|
export function readMachineRuntimeEndpoint(tuttiHome, projectId) {
|
|
@@ -226,7 +235,11 @@ function ensureHostRegistrationSecret(tuttiHome, projectId, now) {
|
|
|
226
235
|
const secretPath = getHostRegistrationSecretPath(tuttiHome, projectId);
|
|
227
236
|
const ref = createHostRegistrationSecretRef(projectId);
|
|
228
237
|
if (existsSync(secretPath)) {
|
|
229
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
ref,
|
|
240
|
+
created: false,
|
|
241
|
+
createdAt: readHostRegistrationSecret(tuttiHome, projectId).created_at,
|
|
242
|
+
};
|
|
230
243
|
}
|
|
231
244
|
const timestamp = now().toISOString();
|
|
232
245
|
const secretFile = {
|
|
@@ -237,7 +250,7 @@ function ensureHostRegistrationSecret(tuttiHome, projectId, now) {
|
|
|
237
250
|
updated_at: timestamp,
|
|
238
251
|
};
|
|
239
252
|
writePrivateJsonFile(secretPath, secretFile);
|
|
240
|
-
return { ref, created: true };
|
|
253
|
+
return { ref, created: true, createdAt: timestamp };
|
|
241
254
|
}
|
|
242
255
|
export function ensureMachineProjectBinding(options) {
|
|
243
256
|
const now = options.now ?? (() => new Date());
|
|
@@ -256,6 +269,7 @@ export function ensureMachineProjectBinding(options) {
|
|
|
256
269
|
workspace_root: workspaceRoot,
|
|
257
270
|
local_store_root: localStoreRoot,
|
|
258
271
|
host_registration_secret_ref: existing?.host_registration_secret_ref ?? secret.ref,
|
|
272
|
+
created_at: existing?.created_at ?? secret.createdAt,
|
|
259
273
|
updated_at: now().toISOString(),
|
|
260
274
|
};
|
|
261
275
|
const displayNameSnapshot = options.displayNameSnapshot?.trim();
|
|
@@ -268,6 +282,13 @@ export function ensureMachineProjectBinding(options) {
|
|
|
268
282
|
if (existing?.relay_project_ref !== undefined) {
|
|
269
283
|
binding.relay_project_ref = existing.relay_project_ref;
|
|
270
284
|
}
|
|
285
|
+
const relayUrl = options.relayUrl?.trim();
|
|
286
|
+
if (relayUrl !== undefined && relayUrl !== "") {
|
|
287
|
+
binding.relay_url = relayUrl;
|
|
288
|
+
}
|
|
289
|
+
else if (existing?.relay_url !== undefined) {
|
|
290
|
+
binding.relay_url = existing.relay_url;
|
|
291
|
+
}
|
|
271
292
|
writePrivateJsonFile(bindingPath, binding);
|
|
272
293
|
return {
|
|
273
294
|
binding,
|
|
@@ -41,6 +41,7 @@ export type MachineProjectHostInspection = {
|
|
|
41
41
|
} | {
|
|
42
42
|
kind: "running";
|
|
43
43
|
identity_verified: boolean;
|
|
44
|
+
version?: string;
|
|
44
45
|
relay_readiness?: "connected" | "not_connected";
|
|
45
46
|
relay_connection_status?: "connecting" | "connected" | "reconnecting" | "unavailable" | "stopped";
|
|
46
47
|
} | {
|
|
@@ -145,6 +145,7 @@ async function inspectHost(options) {
|
|
|
145
145
|
return {
|
|
146
146
|
kind: "running",
|
|
147
147
|
identity_verified: status.project_id === options.projectId,
|
|
148
|
+
...(probe.version === undefined ? {} : { version: probe.version }),
|
|
148
149
|
...(probe.readiness?.relay === undefined ? {} : { relay_readiness: probe.readiness.relay }),
|
|
149
150
|
...(status.relay_connection?.status === undefined
|
|
150
151
|
? {}
|
|
@@ -10,6 +10,7 @@ export type ManagedHostReadyResult = {
|
|
|
10
10
|
export declare function spawnDetachedHost(options: {
|
|
11
11
|
workspaceRoot: string;
|
|
12
12
|
env?: NodeJS.ProcessEnv;
|
|
13
|
+
inheritProcessEnv?: boolean;
|
|
13
14
|
}): void;
|
|
14
15
|
export declare function waitForManagedHostReady(options: {
|
|
15
16
|
tuttiHome: string;
|
|
@@ -30,10 +30,12 @@ export function spawnDetachedHost(options) {
|
|
|
30
30
|
], {
|
|
31
31
|
cwd: options.workspaceRoot,
|
|
32
32
|
detached: true,
|
|
33
|
-
env:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
env: options.inheritProcessEnv === false
|
|
34
|
+
? options.env
|
|
35
|
+
: {
|
|
36
|
+
...process.env,
|
|
37
|
+
...(options.env ?? {}),
|
|
38
|
+
},
|
|
37
39
|
stdio: "ignore",
|
|
38
40
|
});
|
|
39
41
|
child.unref();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ProjectId } from "@tutti/shared/ids";
|
|
1
|
+
import type { ProjectId, RelayProjectRef } from "@tutti/shared/ids";
|
|
2
2
|
import { type FetchLike } from "./host-runtime-endpoint.js";
|
|
3
3
|
import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
|
|
4
4
|
import type { LocalControlFetch } from "./local-control-client.js";
|
|
@@ -9,12 +9,13 @@ export type RuntimeProjectRow = {
|
|
|
9
9
|
workspace_root: string;
|
|
10
10
|
endpoint?: string;
|
|
11
11
|
provider_status?: string;
|
|
12
|
-
relay_project_ref?:
|
|
12
|
+
relay_project_ref?: RelayProjectRef;
|
|
13
13
|
join_url?: string;
|
|
14
14
|
relay_connection_status?: string;
|
|
15
15
|
last_connected_at?: string;
|
|
16
16
|
next_retry_at?: string;
|
|
17
17
|
relay_reason_code?: string;
|
|
18
|
+
host_version?: string;
|
|
18
19
|
};
|
|
19
20
|
export declare function formatProjectIdForDisplay(projectId: ProjectId, projectIds: readonly ProjectId[]): string;
|
|
20
21
|
export declare function listRuntimeProjects(options?: {
|
|
@@ -66,7 +66,8 @@ export function formatProjectIdForDisplay(projectId, projectIds) {
|
|
|
66
66
|
}
|
|
67
67
|
export async function listRuntimeProjects(options = {}) {
|
|
68
68
|
const cwd = options.cwd ?? process.cwd();
|
|
69
|
-
const
|
|
69
|
+
const env = options.env ?? process.env;
|
|
70
|
+
const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
|
|
70
71
|
const rows = [];
|
|
71
72
|
const inspections = await inspectMachineProjects({
|
|
72
73
|
tuttiHome,
|
|
@@ -90,6 +91,9 @@ export async function listRuntimeProjects(options = {}) {
|
|
|
90
91
|
display_name: inspection.display_name,
|
|
91
92
|
workspace_root: inspection.workspace_root ?? "-",
|
|
92
93
|
...(endpoint === undefined ? {} : { endpoint: endpoint.base_url }),
|
|
94
|
+
...(inspection.host.kind !== "running" || inspection.host.version === undefined
|
|
95
|
+
? {}
|
|
96
|
+
: { host_version: inspection.host.version }),
|
|
93
97
|
};
|
|
94
98
|
if (provider !== undefined) {
|
|
95
99
|
row.provider_status = provider.status;
|
|
@@ -97,6 +101,10 @@ export async function listRuntimeProjects(options = {}) {
|
|
|
97
101
|
if (launchStatus?.relay?.relay_project_ref !== undefined) {
|
|
98
102
|
row.relay_project_ref = launchStatus.relay.relay_project_ref;
|
|
99
103
|
}
|
|
104
|
+
else if (inspection.binding.kind === "valid" &&
|
|
105
|
+
inspection.binding.record.relay_project_ref !== undefined) {
|
|
106
|
+
row.relay_project_ref = inspection.binding.record.relay_project_ref;
|
|
107
|
+
}
|
|
100
108
|
if (launchStatus?.relay?.join_url !== undefined) {
|
|
101
109
|
row.join_url = launchStatus.relay.join_url;
|
|
102
110
|
}
|
|
@@ -17,5 +17,4 @@ export declare function pickLocalFolder(options?: {
|
|
|
17
17
|
platform?: NodeJS.Platform;
|
|
18
18
|
env?: NodeJS.ProcessEnv;
|
|
19
19
|
}): Promise<LocalFolderPickResult>;
|
|
20
|
-
export declare function createLocalProjectFolder(parentPath: string, childName: string): string;
|
|
21
20
|
//# sourceMappingURL=folder-picker.d.ts.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { constants, accessSync, existsSync,
|
|
3
|
-
import {
|
|
2
|
+
import { constants, accessSync, existsSync, statSync } from "node:fs";
|
|
3
|
+
import { delimiter, join, resolve } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
function executableInPath(command, env) {
|
|
@@ -75,35 +75,4 @@ export async function pickLocalFolder(options = {}) {
|
|
|
75
75
|
return { kind: "cancelled" };
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
-
function validateChildName(value) {
|
|
79
|
-
const name = value.trim();
|
|
80
|
-
const hasControlCharacter = [...name].some((character) => {
|
|
81
|
-
const codePoint = character.codePointAt(0) ?? 0;
|
|
82
|
-
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
83
|
-
});
|
|
84
|
-
if (name === "" ||
|
|
85
|
-
name === "." ||
|
|
86
|
-
name === ".." ||
|
|
87
|
-
name !== basename(name) ||
|
|
88
|
-
hasControlCharacter) {
|
|
89
|
-
throw new Error("Project folder name must be a single valid directory name.");
|
|
90
|
-
}
|
|
91
|
-
return name;
|
|
92
|
-
}
|
|
93
|
-
export function createLocalProjectFolder(parentPath, childName) {
|
|
94
|
-
const parent = resolve(parentPath.trim());
|
|
95
|
-
if (!existsSync(parent) || !statSync(parent).isDirectory()) {
|
|
96
|
-
throw new Error("Parent folder does not exist or is not a directory.");
|
|
97
|
-
}
|
|
98
|
-
accessSync(parent, constants.W_OK);
|
|
99
|
-
const child = resolve(parent, validateChildName(childName));
|
|
100
|
-
if (dirname(child) !== parent) {
|
|
101
|
-
throw new Error("Project folder must be created directly inside the selected parent.");
|
|
102
|
-
}
|
|
103
|
-
if (existsSync(child)) {
|
|
104
|
-
throw new Error("A file or folder with that name already exists.");
|
|
105
|
-
}
|
|
106
|
-
mkdirSync(child, { mode: 0o755 });
|
|
107
|
-
return child;
|
|
108
|
-
}
|
|
109
78
|
//# sourceMappingURL=folder-picker.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const LOCAL_CONSOLE_INVOCATION_ENV_KEYS: readonly ["DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "LANG", "LC_ALL", "LC_CTYPE", "PATH", "TUTTI_HOST_ENDPOINT_HOST", "TUTTI_HOST_ENDPOINT_PORT", "TUTTI_RELAY_URL", "WAYLAND_DISPLAY", "XDG_CURRENT_DESKTOP"];
|
|
2
|
+
export type LocalConsoleInvocationEnvironment = Partial<Record<(typeof LOCAL_CONSOLE_INVOCATION_ENV_KEYS)[number], string>>;
|
|
3
|
+
export type LocalConsoleInvocationContext = {
|
|
4
|
+
currentDirectory: string;
|
|
5
|
+
environment: LocalConsoleInvocationEnvironment;
|
|
6
|
+
};
|
|
7
|
+
export declare function collectLocalConsoleInvocationEnvironment(env: NodeJS.ProcessEnv): LocalConsoleInvocationEnvironment;
|
|
8
|
+
export declare function createLocalConsoleInvocationContext(options: {
|
|
9
|
+
cwd: string;
|
|
10
|
+
env: NodeJS.ProcessEnv;
|
|
11
|
+
}): LocalConsoleInvocationContext;
|
|
12
|
+
export declare function createLocalConsoleServiceEnvironment(options: {
|
|
13
|
+
env: NodeJS.ProcessEnv;
|
|
14
|
+
tuttiHome: string;
|
|
15
|
+
}): NodeJS.ProcessEnv;
|
|
16
|
+
export declare function createLocalConsoleOperationEnvironment(options: {
|
|
17
|
+
serviceEnvironment: NodeJS.ProcessEnv;
|
|
18
|
+
invocationEnvironment: LocalConsoleInvocationEnvironment;
|
|
19
|
+
tuttiHome: string;
|
|
20
|
+
}): NodeJS.ProcessEnv;
|
|
21
|
+
//# sourceMappingURL=invocation-context.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
export const LOCAL_CONSOLE_INVOCATION_ENV_KEYS = [
|
|
3
|
+
"DBUS_SESSION_BUS_ADDRESS",
|
|
4
|
+
"DISPLAY",
|
|
5
|
+
"LANG",
|
|
6
|
+
"LC_ALL",
|
|
7
|
+
"LC_CTYPE",
|
|
8
|
+
"PATH",
|
|
9
|
+
"TUTTI_HOST_ENDPOINT_HOST",
|
|
10
|
+
"TUTTI_HOST_ENDPOINT_PORT",
|
|
11
|
+
"TUTTI_RELAY_URL",
|
|
12
|
+
"WAYLAND_DISPLAY",
|
|
13
|
+
"XDG_CURRENT_DESKTOP",
|
|
14
|
+
];
|
|
15
|
+
export function collectLocalConsoleInvocationEnvironment(env) {
|
|
16
|
+
const environment = {};
|
|
17
|
+
for (const key of LOCAL_CONSOLE_INVOCATION_ENV_KEYS) {
|
|
18
|
+
const value = env[key];
|
|
19
|
+
if (value !== undefined && value !== "") {
|
|
20
|
+
environment[key] = value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return environment;
|
|
24
|
+
}
|
|
25
|
+
export function createLocalConsoleInvocationContext(options) {
|
|
26
|
+
return {
|
|
27
|
+
currentDirectory: resolve(options.cwd),
|
|
28
|
+
environment: collectLocalConsoleInvocationEnvironment(options.env),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function createLocalConsoleServiceEnvironment(options) {
|
|
32
|
+
const environment = { TUTTI_HOME: options.tuttiHome };
|
|
33
|
+
for (const key of [
|
|
34
|
+
"HOME",
|
|
35
|
+
"LANG",
|
|
36
|
+
"LC_ALL",
|
|
37
|
+
"LC_CTYPE",
|
|
38
|
+
"LOGNAME",
|
|
39
|
+
"TMPDIR",
|
|
40
|
+
"USER",
|
|
41
|
+
]) {
|
|
42
|
+
const value = options.env[key];
|
|
43
|
+
if (value !== undefined && value !== "") {
|
|
44
|
+
environment[key] = value;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return environment;
|
|
48
|
+
}
|
|
49
|
+
export function createLocalConsoleOperationEnvironment(options) {
|
|
50
|
+
return {
|
|
51
|
+
...options.serviceEnvironment,
|
|
52
|
+
...options.invocationEnvironment,
|
|
53
|
+
TUTTI_HOME: options.tuttiHome,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=invocation-context.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type LifecycleLockTiming = {
|
|
2
|
+
staleMs: number;
|
|
3
|
+
waitMs: number;
|
|
4
|
+
pollMs: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function withLocalConsoleLifecycleLock<T>(options: {
|
|
7
|
+
tuttiHome: string;
|
|
8
|
+
run: () => Promise<T>;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
timing?: Partial<LifecycleLockTiming>;
|
|
11
|
+
}): Promise<T>;
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=lifecycle-lock.d.ts.map
|