@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.
Files changed (37) hide show
  1. package/README.md +3 -3
  2. package/dist/server-shell/cli/args.d.ts +6 -0
  3. package/dist/server-shell/cli/args.js +30 -0
  4. package/dist/server-shell/cli/cli.js +118 -6
  5. package/dist/server-shell/cli/errors.d.ts +1 -1
  6. package/dist/server-shell/cli/host-runtime-endpoint.d.ts +1 -0
  7. package/dist/server-shell/cli/host-runtime-endpoint.js +10 -5
  8. package/dist/server-shell/cli/launch.js +1 -0
  9. package/dist/server-shell/cli/machine-local.d.ts +3 -0
  10. package/dist/server-shell/cli/machine-local.js +24 -3
  11. package/dist/server-shell/cli/machine-project-inspector.d.ts +1 -0
  12. package/dist/server-shell/cli/machine-project-inspector.js +1 -0
  13. package/dist/server-shell/cli/managed-host.d.ts +1 -0
  14. package/dist/server-shell/cli/managed-host.js +6 -4
  15. package/dist/server-shell/cli/runtime-commands.d.ts +3 -2
  16. package/dist/server-shell/cli/runtime-commands.js +9 -1
  17. package/dist/server-shell/local-console/folder-picker.d.ts +0 -1
  18. package/dist/server-shell/local-console/folder-picker.js +2 -33
  19. package/dist/server-shell/local-console/invocation-context.d.ts +21 -0
  20. package/dist/server-shell/local-console/invocation-context.js +56 -0
  21. package/dist/server-shell/local-console/lifecycle-lock.d.ts +13 -0
  22. package/dist/server-shell/local-console/lifecycle-lock.js +135 -0
  23. package/dist/server-shell/local-console/managed-console.d.ts +38 -0
  24. package/dist/server-shell/local-console/managed-console.js +254 -36
  25. package/dist/server-shell/local-console/project-service.d.ts +16 -9
  26. package/dist/server-shell/local-console/project-service.js +152 -58
  27. package/dist/server-shell/local-console/runtime-endpoint.d.ts +15 -1
  28. package/dist/server-shell/local-console/runtime-endpoint.js +21 -5
  29. package/dist/server-shell/local-console/server.d.ts +12 -0
  30. package/dist/server-shell/local-console/server.js +152 -45
  31. package/dist/server-shell/local-console/session.d.ts +10 -15
  32. package/dist/server-shell/local-console/session.js +69 -17
  33. package/package.json +1 -1
  34. package/web/assets/index-DPuaTngs.js +29 -0
  35. package/web/assets/{index-BcZpeSaX.css → index-Dmbchapb.css} +1 -1
  36. package/web/index.html +2 -2
  37. 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` 启动 / 复用的 loopback Local Console 与既有项目 Host 编排
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 套件、机器级 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 接入。
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 文档为准。
@@ -5,6 +5,12 @@ export type CliCommand = {
5
5
  topic?: string;
6
6
  } | {
7
7
  kind: "version";
8
+ } | {
9
+ kind: "service";
10
+ action: "status" | "stop" | "restart";
11
+ } | {
12
+ kind: "shutdown";
13
+ yes: boolean;
8
14
  } | {
9
15
  kind: "launch";
10
16
  target?: string;
@@ -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 the machine-local project console in your browser.
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 Local Console is ready at ${console.endpoint.base_url}/console\n`);
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 Local Console in a browser:", console.url, ""].join("\n"));
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 readiness.relay === "connected" || readiness.relay === "not_connected"
23
- ? { relay: readiness.relay }
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.relay === undefined ? {} : { 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 { ref, created: false };
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
- ...process.env,
35
- ...(options.env ?? {}),
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?: string;
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 tuttiHome = resolveTuttiHome(options.env?.TUTTI_HOME, cwd);
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, mkdirSync, statSync } from "node:fs";
3
- import { basename, delimiter, dirname, join, resolve } from "node:path";
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