@xfey/tutti 0.1.16 → 0.1.17

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.
@@ -40,5 +40,8 @@ export declare function runControlPlaneTaskCompileProcedure(options: {
40
40
  kind: "workflow";
41
41
  workflow_ref: `wf_${string}`;
42
42
  };
43
+ } | {
44
+ kind: "failed";
45
+ summary: string;
43
46
  }>;
44
47
  //# sourceMappingURL=task-compile-start.d.ts.map
@@ -51,7 +51,7 @@ export function startControlPlaneTaskCompile(options) {
51
51
  sourceBatch,
52
52
  });
53
53
  }
54
- if (scratchpad.state !== "ready") {
54
+ if (!scratchpadHasSubmittableContent(scratchpad)) {
55
55
  return { disposition: { kind: "scratchpad_empty" } };
56
56
  }
57
57
  if (shouldInitializeProjectContext) {
@@ -66,6 +66,10 @@ export function startControlPlaneTaskCompile(options) {
66
66
  });
67
67
  }
68
68
  function startControlPlaneTaskCompileActivity(options) {
69
+ const scratchpad = readScratchpadProjection(options.store.db);
70
+ if (!scratchpadHasSubmittableContent(scratchpad)) {
71
+ return { disposition: { kind: "scratchpad_empty" } };
72
+ }
69
73
  const workflowRef = createWorkflowInvocationRef();
70
74
  const start = options.engine.startProcedure({
71
75
  workflow_ref: workflowRef,
@@ -120,13 +124,18 @@ function startScratchpadRefreshBeforeTaskCompileActivity(options) {
120
124
  now: options.now,
121
125
  onTransition: (state) => {
122
126
  options.publishProcedureTransition(state);
123
- if (state.activity.status === "finished") {
124
- if (shouldInitializeControlPlaneProjectContext(options)) {
125
- void startProjectContextBootstrapActivity(options);
126
- }
127
- else {
128
- void startControlPlaneTaskCompileActivity(options);
129
- }
127
+ if (state.transition_kind !== "terminal" || state.activity.status !== "finished") {
128
+ return;
129
+ }
130
+ const scratchpad = readScratchpadProjection(options.store.db);
131
+ if (!scratchpadHasSubmittableContent(scratchpad)) {
132
+ return;
133
+ }
134
+ if (shouldInitializeControlPlaneProjectContext(options)) {
135
+ void startProjectContextBootstrapActivity(options);
136
+ }
137
+ else {
138
+ void startControlPlaneTaskCompileActivity(options);
130
139
  }
131
140
  },
132
141
  onError: ({ context, error }) => logProcedureExecutionError({
@@ -186,9 +195,17 @@ function startProjectContextBootstrapActivity(options) {
186
195
  now: options.now,
187
196
  onTransition: (state) => {
188
197
  options.publishProcedureTransition(state);
189
- if (state.activity.status === "finished") {
190
- void startControlPlaneTaskCompileActivity(options);
198
+ if (state.transition_kind !== "terminal" || state.activity.status !== "finished") {
199
+ return;
200
+ }
201
+ if (shouldInitializeControlPlaneProjectContext(options)) {
202
+ return;
191
203
  }
204
+ const scratchpad = readScratchpadProjection(options.store.db);
205
+ if (!scratchpadHasSubmittableContent(scratchpad)) {
206
+ return;
207
+ }
208
+ void startControlPlaneTaskCompileActivity(options);
192
209
  },
193
210
  onError: ({ context, error }) => logProcedureExecutionError({
194
211
  logger: options.logger,
@@ -261,9 +278,16 @@ function startProjectContextBootstrapActivity(options) {
261
278
  };
262
279
  }
263
280
  export async function runControlPlaneTaskCompileProcedure(options) {
281
+ const scratchpad = readScratchpadProjection(options.store.db);
282
+ if (!scratchpadHasSubmittableContent(scratchpad)) {
283
+ return {
284
+ kind: "failed",
285
+ summary: "Scratchpad is empty. Discuss more before running.",
286
+ };
287
+ }
264
288
  const output = await options.runner.runTaskCompile({
265
289
  workflow_ref: options.workflowRef,
266
- scratchpad: readScratchpadProjection(options.store.db),
290
+ scratchpad,
267
291
  worklist: readWorklistProjection(options.store.db),
268
292
  }, {
269
293
  workflow_ref: options.workflowRef,
@@ -282,4 +306,16 @@ export async function runControlPlaneTaskCompileProcedure(options) {
282
306
  onTasksAdded: options.onTasksAdded,
283
307
  });
284
308
  }
309
+ function scratchpadHasSubmittableContent(scratchpad) {
310
+ if (scratchpad.state !== "ready") {
311
+ return false;
312
+ }
313
+ return [
314
+ scratchpad.topic,
315
+ scratchpad.background_summary,
316
+ ...scratchpad.current_consensus,
317
+ ...scratchpad.open_questions,
318
+ ...scratchpad.task_changes,
319
+ ].some((item) => item.trim().length > 0);
320
+ }
285
321
  //# sourceMappingURL=task-compile-start.js.map
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { formatCliError, formatCliErrorReason } from "./errors.js";
2
+ import { formatCliError } from "./errors.js";
3
3
  import { parseCliArgs } from "./args.js";
4
4
  import { runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
5
5
  import { resolveExistingProjectContext } from "./project-resolver.js";
6
6
  import { runProviderSetupTui } from "./provider-tui.js";
7
- import { runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, } from "./runtime-commands.js";
7
+ import { runDoctorCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, } from "./runtime-commands.js";
8
8
  import { readCliVersion } from "./version.js";
9
9
  const HELP_TEXT = `Usage:
10
10
  tutti launch [target] [--yes] [--foreground]
@@ -108,25 +108,6 @@ async function runProviderSetupCommand(target) {
108
108
  ].join("\n"));
109
109
  process.stdout.write("\n");
110
110
  }
111
- function runDoctorCommand(target) {
112
- const checks = [];
113
- checks.push(`Node: ${process.version}`);
114
- checks.push(`Tutti: ${readCliVersion()}`);
115
- try {
116
- const project = resolveExistingProjectContext({
117
- ...(target === undefined ? {} : { target }),
118
- });
119
- checks.push(`Project: ${project.display_name}`);
120
- checks.push(`Project ID: ${project.project_id}`);
121
- checks.push(`Provider: ${project.provider_config.status}`);
122
- checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
123
- }
124
- catch (error) {
125
- checks.push("Project: unavailable");
126
- checks.push(`Project detail: ${formatCliErrorReason(error)}`);
127
- }
128
- process.stdout.write(`${checks.join("\n")}\n`);
129
- }
130
111
  try {
131
112
  const command = parseCliArgs(process.argv.slice(2));
132
113
  switch (command.kind) {
@@ -171,7 +152,7 @@ try {
171
152
  process.stdout.write(`${runProviderStatusCommand(command.target)}\n`);
172
153
  break;
173
154
  case "doctor":
174
- runDoctorCommand(command.target);
155
+ process.stdout.write(`${runDoctorCommand(command.target)}\n`);
175
156
  break;
176
157
  }
177
158
  }
@@ -61,6 +61,10 @@ export declare function deleteMachineRuntimeEndpoint(options: {
61
61
  projectId: ProjectId;
62
62
  expectedToken?: string;
63
63
  }): boolean;
64
+ export declare function deleteMachineProjectLocalState(options: {
65
+ tuttiHome: string;
66
+ projectId: ProjectId;
67
+ }): boolean;
64
68
  export declare function readHostRegistrationSecret(tuttiHome: string, projectId: ProjectId): HostRegistrationSecretFile;
65
69
  export declare function updateMachineProjectRelayBinding(options: {
66
70
  tuttiHome: string;
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
5
5
  import { LaunchError } from "./errors.js";
@@ -150,6 +150,20 @@ export function deleteMachineRuntimeEndpoint(options) {
150
150
  unlinkSync(endpointPath);
151
151
  return true;
152
152
  }
153
+ export function deleteMachineProjectLocalState(options) {
154
+ let deleted = false;
155
+ const localStoreRoot = getProjectLocalStoreRoot(options.tuttiHome, options.projectId);
156
+ if (existsSync(localStoreRoot)) {
157
+ rmSync(localStoreRoot, { recursive: true, force: true });
158
+ deleted = true;
159
+ }
160
+ const secretPath = getHostRegistrationSecretPath(options.tuttiHome, options.projectId);
161
+ if (existsSync(secretPath)) {
162
+ unlinkSync(secretPath);
163
+ deleted = true;
164
+ }
165
+ return deleted;
166
+ }
153
167
  export function readHostRegistrationSecret(tuttiHome, projectId) {
154
168
  const readResult = readJsonFile(getHostRegistrationSecretPath(tuttiHome, projectId));
155
169
  if (readResult.kind === "missing") {
@@ -11,6 +11,9 @@ export type ExistingProjectContext = {
11
11
  provider_config: OpenAiProviderConfigProjection;
12
12
  provider_base_url?: string;
13
13
  };
14
+ export type ManagedProjectContext = ExistingProjectContext & {
15
+ workspace_exists: boolean;
16
+ };
14
17
  export declare function resolveLaunchWorkspacePath(options: {
15
18
  target?: string;
16
19
  cwd?: string;
@@ -22,4 +25,9 @@ export declare function resolveExistingProjectContext(options: {
22
25
  cwd?: string;
23
26
  env?: NodeJS.ProcessEnv;
24
27
  }): ExistingProjectContext;
28
+ export declare function resolveManagedProjectContext(options: {
29
+ target?: string;
30
+ cwd?: string;
31
+ env?: NodeJS.ProcessEnv;
32
+ }): ManagedProjectContext;
25
33
  //# sourceMappingURL=project-resolver.d.ts.map
@@ -70,28 +70,50 @@ function resolveSingleTargetMatch(target, matches) {
70
70
  throw new LaunchError("project_target_not_found", `Project target was not found: ${target}`, "Run `tutti ps` to list known projects, or pass a project workspace path.");
71
71
  }
72
72
  function resolveProjectTargetWorkspaceRoot(options) {
73
+ return resolveProjectTargetEntry(options).workspace_root;
74
+ }
75
+ function resolveProjectTargetEntry(options) {
73
76
  const target = options.target.trim();
74
77
  if (target === "") {
75
- return resolve(options.cwd, ".");
78
+ return readProjectTargetEntryFromWorkspace(resolve(options.cwd, "."));
76
79
  }
77
80
  const pathCandidate = resolve(options.cwd, target);
78
81
  if (existsSync(pathCandidate)) {
79
- return pathCandidate;
82
+ return readProjectTargetEntryFromWorkspace(pathCandidate);
80
83
  }
81
84
  const entries = listProjectTargetEntries(options.tuttiHome);
85
+ const pathMatches = entries.filter((entry) => resolve(entry.workspace_root) === pathCandidate);
86
+ if (pathMatches.length > 0) {
87
+ return resolveSingleTargetMatch(target, pathMatches);
88
+ }
82
89
  const exactId = entries.filter((entry) => entry.project_id === target);
83
90
  if (exactId.length > 0) {
84
- return resolveSingleTargetMatch(target, exactId).workspace_root;
91
+ return resolveSingleTargetMatch(target, exactId);
85
92
  }
86
93
  if (target.startsWith(ID_PREFIXES.project)) {
87
94
  const idPrefix = entries.filter((entry) => entry.project_id.startsWith(target));
88
95
  if (idPrefix.length > 0) {
89
- return resolveSingleTargetMatch(target, idPrefix).workspace_root;
96
+ return resolveSingleTargetMatch(target, idPrefix);
90
97
  }
91
98
  }
92
99
  const normalizedTarget = normalizeTargetName(target);
93
100
  const nameMatches = entries.filter((entry) => entry.aliases.some((alias) => normalizeTargetName(alias) === normalizedTarget));
94
- return resolveSingleTargetMatch(target, nameMatches).workspace_root;
101
+ return resolveSingleTargetMatch(target, nameMatches);
102
+ }
103
+ function readProjectTargetEntryFromWorkspace(workspaceRoot) {
104
+ assertSafeProjectRoot(workspaceRoot);
105
+ const identity = readProjectIdentity(workspaceRoot);
106
+ if (identity.kind !== "present") {
107
+ throw new LaunchError("project_identity_conflict", "Current directory is not a Tutti project", "Run `tutti launch` from a project root first.");
108
+ }
109
+ const workspaceName = basename(workspaceRoot) || identity.project_id;
110
+ const displayName = readProjectDisplayName(workspaceRoot) ?? workspaceName;
111
+ return {
112
+ project_id: identity.project_id,
113
+ workspace_root: workspaceRoot,
114
+ display_name: displayName,
115
+ aliases: Array.from(new Set([displayName, workspaceName].filter((alias) => alias.trim() !== ""))),
116
+ };
95
117
  }
96
118
  export function resolveLaunchWorkspacePath(options) {
97
119
  const cwd = options.cwd ?? process.cwd();
@@ -154,4 +176,44 @@ export function resolveExistingProjectContext(options) {
154
176
  : {}),
155
177
  };
156
178
  }
179
+ export function resolveManagedProjectContext(options) {
180
+ const cwd = options.cwd ?? process.cwd();
181
+ const env = options.env ?? process.env;
182
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
183
+ if (options.target === undefined || existsSync(resolve(cwd, options.target))) {
184
+ const existing = resolveExistingProjectContext({
185
+ ...(options.target === undefined ? {} : { target: options.target }),
186
+ cwd,
187
+ env,
188
+ });
189
+ return {
190
+ ...existing,
191
+ workspace_exists: true,
192
+ };
193
+ }
194
+ const entry = resolveProjectTargetEntry({
195
+ target: options.target,
196
+ cwd,
197
+ tuttiHome,
198
+ });
199
+ const binding = readBinding(tuttiHome, entry.project_id);
200
+ const runtimeEndpoint = readRuntimeEndpoint(tuttiHome, entry.project_id);
201
+ const projectConfig = readProjectOpenAiProviderConfig(tuttiHome, entry.project_id);
202
+ return {
203
+ workspace_root: entry.workspace_root,
204
+ workspace_exists: existsSync(entry.workspace_root),
205
+ tutti_home: tuttiHome,
206
+ project_id: entry.project_id,
207
+ display_name: entry.display_name,
208
+ binding,
209
+ runtime_endpoint: runtimeEndpoint,
210
+ provider_config: readOpenAiProviderConfigProjection({
211
+ tuttiHome,
212
+ projectId: entry.project_id,
213
+ }),
214
+ ...(projectConfig.kind === "ok" && projectConfig.config.api_base_url !== undefined
215
+ ? { provider_base_url: projectConfig.config.api_base_url }
216
+ : {}),
217
+ };
218
+ }
157
219
  //# sourceMappingURL=project-resolver.js.map
@@ -1,5 +1,12 @@
1
1
  import { type ConfigureProjectOpenAiProviderResult } from "../../providers/openai/index.js";
2
2
  import type { ProjectId } from "@tutti/shared/ids";
3
+ type ProviderSetupStep = "base-url" | "api-key";
4
+ type ProviderFormState = {
5
+ step: ProviderSetupStep;
6
+ baseUrl: string;
7
+ apiKey: string;
8
+ error: string | undefined;
9
+ };
3
10
  type ProviderSetupLogEvent = {
4
11
  ts: string;
5
12
  level: "error";
@@ -17,6 +24,11 @@ export type ProviderSetupResult = {
17
24
  default_model: string;
18
25
  validated_at: string;
19
26
  };
27
+ export declare function renderProviderForm(options: {
28
+ state: ProviderFormState;
29
+ footer?: string;
30
+ tty?: boolean;
31
+ }): string;
20
32
  export declare function providerValidationFailureMessage(result: ConfigureProjectOpenAiProviderResult): string;
21
33
  export declare function writeProviderSetupValidationLog(options: {
22
34
  tuttiHome: string;
@@ -9,54 +9,51 @@ const SHOW_CURSOR = "\u001B[?25h";
9
9
  const BLINK_ON = "\u001B[5m";
10
10
  const BLINK_OFF = "\u001B[25m";
11
11
  const CLEAR = "\u001B[2J\u001B[H";
12
- const DIVIDER = "----------------------------------------------------------------";
12
+ const FIELD_UNDERLINE = "------------------------------------------------------------";
13
13
  function providerSetupCancelled() {
14
14
  return new LaunchError("provider_setup_cancelled", "Provider setup cancelled.", "Run the command again when you are ready.");
15
15
  }
16
- function fieldLine(options) {
16
+ function renderFieldInput(options) {
17
17
  const value = options.secret && options.value.length > 0 ? "*".repeat(options.value.length) : options.value;
18
- return `> ${options.label}: ${value}${BLINK_ON}_${BLINK_OFF}`;
18
+ return [options.label, `${value}${BLINK_ON}_${BLINK_OFF}`, FIELD_UNDERLINE];
19
19
  }
20
20
  function renderProviderField(state) {
21
21
  if (state.step === "base-url") {
22
22
  return [
23
- "Step 1 of 2 - Base URL",
24
- "Enter the base URL for your OpenAI-compatible provider.",
23
+ "Step 1/2 Base URL",
24
+ "OpenAI-compatible Responses API base URL; must expose POST /responses.",
25
25
  "",
26
- fieldLine({
26
+ ...renderFieldInput({
27
27
  label: "Base URL",
28
28
  value: state.baseUrl,
29
29
  }),
30
30
  "",
31
- "[Enter] to continue",
31
+ "[Enter] Continue [Esc] Cancel",
32
32
  ];
33
33
  }
34
34
  return [
35
- "Step 2 of 2 - API Key",
36
- "Enter the API key for this provider. It will be stored only in the machine-local Tutti credential store.",
35
+ "Step 2/2 API Key",
36
+ "Stored only in the machine-local Tutti credential store.",
37
37
  "",
38
- fieldLine({
38
+ ...renderFieldInput({
39
39
  label: "API Key",
40
40
  value: state.apiKey,
41
41
  secret: true,
42
42
  }),
43
43
  "",
44
44
  state.error === undefined
45
- ? "[Enter] to validate [Esc] to edit Base URL"
46
- : "[Enter] to retry [Esc] to edit Base URL",
45
+ ? "[Enter] Validate [Esc] Back to Base URL"
46
+ : "[Enter] Retry [Esc] Back to Base URL",
47
47
  ];
48
48
  }
49
- function renderProviderForm(options) {
49
+ export function renderProviderForm(options) {
50
50
  const lines = [
51
51
  CLEAR,
52
52
  HIDE_CURSOR,
53
53
  renderTuttiTerminalLogo({ tty: options.tty === true }),
54
54
  "",
55
55
  "Provider setup",
56
- DIVIDER,
57
- "Tutti uses an OpenAI-compatible Responses API provider.",
58
- "The base URL should expose POST /responses under the entered URL.",
59
- DIVIDER,
56
+ "Connect a provider compatible with OpenAI's Responses API.",
60
57
  "",
61
58
  ...renderProviderField(options.state),
62
59
  "",
@@ -209,9 +206,7 @@ function renderChecking(frame, tty) {
209
206
  renderTuttiTerminalLogo({ tty }),
210
207
  "",
211
208
  "Provider setup",
212
- DIVIDER,
213
- "Validating the provider with the configured Responses API endpoint.",
214
- DIVIDER,
209
+ "Validating the configured Responses API endpoint.",
215
210
  "",
216
211
  `${indicator} Checking provider connection with ${DEFAULT_OPENAI_MODEL}...`,
217
212
  ].join("\n");
@@ -1,6 +1,7 @@
1
1
  import { type ProjectId } from "@tutti/shared/ids";
2
2
  import { type FetchLike } from "./host-runtime-endpoint.js";
3
3
  import { type MachineRuntimeEndpointRecord } from "./machine-local.js";
4
+ import type { LocalControlFetch } from "./local-control-client.js";
4
5
  export type RuntimeProjectRow = {
5
6
  project_id: ProjectId;
6
7
  status: "online" | "stale";
@@ -22,10 +23,18 @@ export declare function runPsManageCommand(options?: {
22
23
  stdin?: NodeJS.ReadStream;
23
24
  stdout?: NodeJS.WriteStream;
24
25
  }): Promise<void>;
25
- export declare function runStopCommand(target?: string): Promise<string>;
26
+ export declare function runStopCommand(target?: string, options?: {
27
+ cwd?: string;
28
+ env?: NodeJS.ProcessEnv;
29
+ fetchImpl?: LocalControlFetch;
30
+ }): Promise<string>;
26
31
  export declare function runInviteCommand(target?: string): Promise<string>;
27
32
  export declare function runProviderStatusCommand(target?: string): string;
28
33
  export declare function runLogsCommand(target?: string, tailLines?: number): string;
34
+ export declare function runDoctorCommand(target?: string, options?: {
35
+ cwd?: string;
36
+ env?: NodeJS.ProcessEnv;
37
+ }): string;
29
38
  export declare function readRuntimeEndpointForProject(tuttiHome: string, projectId: ProjectId): MachineRuntimeEndpointRecord | null;
30
39
  export declare function runtimeEndpointPathForProject(tuttiHome: string, projectId: ProjectId): string;
31
40
  export declare function projectLocalStoreRoot(tuttiHome: string, projectId: ProjectId): string;
@@ -4,16 +4,19 @@ import { emitKeypressEvents } from "node:readline";
4
4
  import { ID_PREFIXES, isPrefixedId } from "@tutti/shared/ids";
5
5
  import { redactText } from "@tutti/shared/utils";
6
6
  import { createRuntimeEndpointProbe } from "./host-runtime-endpoint.js";
7
- import { getHostLogFilePath, getMachineRuntimeEndpointPath, getProjectLocalStoreRoot, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
7
+ import { deleteMachineProjectLocalState, getHostLogFilePath, getMachineRuntimeEndpointPath, getProjectLocalStoreRoot, readMachineProjectBinding, readMachineRuntimeEndpoint, } from "./machine-local.js";
8
8
  import { readHostLocalLaunchStatus, readHostLocalProject, readHostLocalProviderConfig, requestHostLocalShutdown, rotateHostLocalInvite, } from "./local-control-client.js";
9
9
  import { formatCliErrorReason, LaunchError } from "./errors.js";
10
- import { resolveExistingProjectContext } from "./project-resolver.js";
10
+ import { resolveManagedProjectContext } from "./project-resolver.js";
11
11
  import { formatJoinLinkValidity, renderTerminalQr } from "./terminal-qr.js";
12
12
  import { resolveTuttiHome } from "../../providers/openai/index.js";
13
+ import { readCliVersion } from "./version.js";
13
14
  const PROJECT_ID_DISPLAY_MIN_LENGTH = 12;
14
- function resolveProject(target) {
15
- return resolveExistingProjectContext({
15
+ function resolveProject(target, options = {}) {
16
+ return resolveManagedProjectContext({
16
17
  ...(target === undefined ? {} : { target }),
18
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
19
+ ...(options.env === undefined ? {} : { env: options.env }),
17
20
  });
18
21
  }
19
22
  function projectIdsFromTuttiHome(tuttiHome) {
@@ -271,18 +274,35 @@ export async function runPsManageCommand(options = {}) {
271
274
  stdout.write(SHOW_CURSOR);
272
275
  }
273
276
  }
274
- export async function runStopCommand(target) {
275
- const project = resolveProject(target);
277
+ export async function runStopCommand(target, options = {}) {
278
+ const project = resolveProject(target, options);
276
279
  const endpoint = project.runtime_endpoint;
277
280
  if (endpoint === null) {
281
+ if (!project.workspace_exists) {
282
+ deleteMachineProjectLocalState({
283
+ tuttiHome: project.tutti_home,
284
+ projectId: project.project_id,
285
+ });
286
+ return `Deleted ${project.display_name}; the workspace no longer exists.`;
287
+ }
278
288
  return "Tutti host is not running for this project.";
279
289
  }
280
290
  try {
281
- await requestHostLocalShutdown({ endpoint });
291
+ await requestHostLocalShutdown({
292
+ endpoint,
293
+ ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }),
294
+ });
282
295
  }
283
296
  catch (error) {
284
297
  throw new LaunchError("existing_server_unhealthy", `Could not stop the host: ${formatCliErrorReason(error)}`, "Run `tutti ps` to inspect the runtime state, or stop the host process manually.");
285
298
  }
299
+ if (!project.workspace_exists) {
300
+ deleteMachineProjectLocalState({
301
+ tuttiHome: project.tutti_home,
302
+ projectId: project.project_id,
303
+ });
304
+ return `Stopped and deleted ${project.display_name}; the workspace no longer exists.`;
305
+ }
286
306
  return `Stopped ${project.display_name}.`;
287
307
  }
288
308
  export async function runInviteCommand(target) {
@@ -339,6 +359,24 @@ export function runLogsCommand(target, tailLines = 120) {
339
359
  const lines = readFileSync(logPath, "utf8").split(/\r?\n/u).filter(Boolean).slice(-tailLines);
340
360
  return lines.length === 0 ? "Host log is empty." : lines.join("\n");
341
361
  }
362
+ export function runDoctorCommand(target, options = {}) {
363
+ const checks = [];
364
+ checks.push(`Node: ${process.version}`);
365
+ checks.push(`Tutti: ${readCliVersion()}`);
366
+ try {
367
+ const project = resolveProject(target, options);
368
+ checks.push(`Project: ${project.display_name}`);
369
+ checks.push(`Project ID: ${project.project_id}`);
370
+ checks.push(`Workspace: ${project.workspace_exists ? redactText(project.workspace_root) : "missing"}`);
371
+ checks.push(`Provider: ${project.provider_config.status}`);
372
+ checks.push(`Host: ${project.runtime_endpoint === null ? "not running" : project.runtime_endpoint.base_url}`);
373
+ }
374
+ catch (error) {
375
+ checks.push("Project: unavailable");
376
+ checks.push(`Project detail: ${formatCliErrorReason(error)}`);
377
+ }
378
+ return checks.join("\n");
379
+ }
342
380
  export function readRuntimeEndpointForProject(tuttiHome, projectId) {
343
381
  return readEndpointFile(tuttiHome, projectId);
344
382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xfey/tutti",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",