@xfey/tutti 0.1.32 → 0.1.33

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 (32) hide show
  1. package/README.md +4 -3
  2. package/dist/server-shell/cli/args.d.ts +4 -0
  3. package/dist/server-shell/cli/args.js +10 -1
  4. package/dist/server-shell/cli/cli.js +22 -3
  5. package/dist/server-shell/cli/host-server-runtime.js +2 -10
  6. package/dist/server-shell/http/routes/project-api/project-timeline-projection.js +4 -4
  7. package/dist/server-shell/http/static-web.d.ts +1 -0
  8. package/dist/server-shell/http/static-web.js +14 -1
  9. package/dist/server-shell/local-console/browser-open.d.ts +5 -0
  10. package/dist/server-shell/local-console/browser-open.js +40 -0
  11. package/dist/server-shell/local-console/folder-picker.d.ts +21 -0
  12. package/dist/server-shell/local-console/folder-picker.js +109 -0
  13. package/dist/server-shell/local-console/index.d.ts +4 -0
  14. package/dist/server-shell/local-console/index.js +4 -0
  15. package/dist/server-shell/local-console/managed-console.d.ts +9 -0
  16. package/dist/server-shell/local-console/managed-console.js +94 -0
  17. package/dist/server-shell/local-console/project-service.d.ts +57 -0
  18. package/dist/server-shell/local-console/project-service.js +217 -0
  19. package/dist/server-shell/local-console/runtime-endpoint.d.ts +21 -0
  20. package/dist/server-shell/local-console/runtime-endpoint.js +74 -0
  21. package/dist/server-shell/local-console/server.d.ts +17 -0
  22. package/dist/server-shell/local-console/server.js +301 -0
  23. package/dist/server-shell/local-console/session.d.ts +33 -0
  24. package/dist/server-shell/local-console/session.js +116 -0
  25. package/package.json +1 -1
  26. package/web/assets/index-BcZpeSaX.css +1 -0
  27. package/web/assets/index-DKf4jz_E.js +29 -0
  28. package/web/assets/tutti_avatar-BBhaZGi3.png +0 -0
  29. package/web/index.html +6 -3
  30. package/web/assets/index-17FuaN3j.js +0 -29
  31. package/web/assets/index-C3nAJcU3.css +0 -1
  32. package/web/assets/tutti_avatar-DAVuzlig.png +0 -0
@@ -0,0 +1,217 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { configureProjectOpenAiProvider, discoverOpenAiModels, readOpenAiProviderConfigProjection, resolveTuttiHome, } from "../../providers/openai/index.js";
4
+ import { formatCliErrorReason, LaunchError } from "../cli/errors.js";
5
+ import { prepareLaunchProject } from "../cli/launch.js";
6
+ import { rotateHostLocalInvite } from "../cli/local-control-client.js";
7
+ import { spawnDetachedHost, waitForManagedHostReady } from "../cli/managed-host.js";
8
+ import { resolveManagedProjectContext } from "../cli/project-resolver.js";
9
+ import { listRuntimeProjects, runStopCommand, } from "../cli/runtime-commands.js";
10
+ export class LocalConsoleProjectError extends Error {
11
+ code;
12
+ statusCode;
13
+ constructor(code, message, statusCode = 422) {
14
+ super(message);
15
+ this.code = code;
16
+ this.statusCode = statusCode;
17
+ this.name = "LocalConsoleProjectError";
18
+ }
19
+ }
20
+ function validateWorkspacePath(value) {
21
+ const path = resolve(value.trim());
22
+ if (value.trim() === "" || !existsSync(path) || !statSync(path).isDirectory()) {
23
+ throw new LocalConsoleProjectError("project_folder_invalid", "Choose an existing project folder before launching.");
24
+ }
25
+ return path;
26
+ }
27
+ function validateProvider(input) {
28
+ const provider = {
29
+ base_url: input.base_url.trim(),
30
+ api_key: input.api_key.trim(),
31
+ model: input.model.trim(),
32
+ };
33
+ if (provider.base_url === "" || provider.api_key === "" || provider.model === "") {
34
+ throw new LocalConsoleProjectError("provider_configuration_required", "Base URL, API key, and model are required for a new Provider configuration.");
35
+ }
36
+ try {
37
+ const url = new URL(provider.base_url);
38
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
39
+ throw new Error("unsupported protocol");
40
+ }
41
+ }
42
+ catch {
43
+ throw new LocalConsoleProjectError("provider_base_url_invalid", "Enter a valid HTTP or HTTPS Provider Base URL.");
44
+ }
45
+ return provider;
46
+ }
47
+ function projectFromRuntimeRow(row, providerStatus) {
48
+ return {
49
+ project_id: row.project_id,
50
+ display_name: row.display_name,
51
+ workspace_path: row.workspace_root,
52
+ status: row.status,
53
+ provider_status: providerStatus,
54
+ ...(row.relay_project_ref === undefined ? {} : { relay_project_ref: row.relay_project_ref }),
55
+ ...(row.join_url === undefined ? {} : { join_url: row.join_url }),
56
+ ...(row.relay_connection_status === undefined
57
+ ? {}
58
+ : { relay_connection_status: row.relay_connection_status }),
59
+ };
60
+ }
61
+ export class LocalConsoleProjectService {
62
+ #cwd;
63
+ #env;
64
+ #launches = new Map();
65
+ constructor(options = {}) {
66
+ this.#cwd = resolve(options.cwd ?? process.cwd());
67
+ this.#env = options.env ?? process.env;
68
+ }
69
+ async listProjects() {
70
+ const tuttiHome = resolveTuttiHome(this.#env.TUTTI_HOME, this.#cwd);
71
+ const rows = await listRuntimeProjects({
72
+ all: true,
73
+ cwd: this.#cwd,
74
+ env: this.#env,
75
+ });
76
+ return rows.map((row) => {
77
+ const provider = readOpenAiProviderConfigProjection({
78
+ tuttiHome,
79
+ projectId: row.project_id,
80
+ });
81
+ return projectFromRuntimeRow(row, provider.status);
82
+ });
83
+ }
84
+ async discoverModels(input) {
85
+ const provider = validateProvider({
86
+ base_url: input.baseUrl,
87
+ api_key: input.apiKey,
88
+ model: "model-discovery",
89
+ });
90
+ return await discoverOpenAiModels({
91
+ apiBaseUrl: provider.base_url,
92
+ apiKey: provider.api_key,
93
+ });
94
+ }
95
+ async launchProject(input) {
96
+ const workspacePath = validateWorkspacePath(input.workspacePath);
97
+ const active = this.#launches.get(workspacePath);
98
+ if (active !== undefined) {
99
+ return await active;
100
+ }
101
+ const launch = this.#launchProject({
102
+ workspacePath,
103
+ ...(input.provider === undefined ? {} : { provider: validateProvider(input.provider) }),
104
+ }).finally(() => {
105
+ this.#launches.delete(workspacePath);
106
+ });
107
+ this.#launches.set(workspacePath, launch);
108
+ return await launch;
109
+ }
110
+ async #launchProject(input) {
111
+ let preparation;
112
+ try {
113
+ preparation = await prepareLaunchProject({
114
+ workspacePath: input.workspacePath,
115
+ yes: true,
116
+ cwd: this.#cwd,
117
+ env: this.#env,
118
+ });
119
+ }
120
+ catch (error) {
121
+ throw this.#normalizeError(error);
122
+ }
123
+ if (input.provider !== undefined) {
124
+ const configured = await configureProjectOpenAiProvider({
125
+ tuttiHome: preparation.tutti_home,
126
+ projectId: preparation.project_id,
127
+ apiBaseUrl: input.provider.base_url,
128
+ apiKey: input.provider.api_key,
129
+ defaultModel: input.provider.model,
130
+ });
131
+ if (configured.kind === "validation_failed") {
132
+ throw new LocalConsoleProjectError(configured.reason, "Provider validation failed. Check the Base URL, API key, and selected model.");
133
+ }
134
+ }
135
+ else if (preparation.provider_status !== "configured") {
136
+ throw new LocalConsoleProjectError("provider_configuration_required", "This project does not have a valid saved Provider. Configure it in the launch form.");
137
+ }
138
+ try {
139
+ spawnDetachedHost({
140
+ workspaceRoot: preparation.workspace_root,
141
+ env: this.#env,
142
+ });
143
+ const ready = await waitForManagedHostReady({
144
+ tuttiHome: preparation.tutti_home,
145
+ projectId: preparation.project_id,
146
+ workspaceRoot: preparation.workspace_root,
147
+ });
148
+ if (ready.join_url === undefined) {
149
+ throw new LocalConsoleProjectError("relay_registration_failed", "The host started, but Relay did not return a visible join link.");
150
+ }
151
+ const projects = await this.listProjects();
152
+ const project = projects.find((item) => item.project_id === preparation.project_id);
153
+ if (project === undefined) {
154
+ throw new LocalConsoleProjectError("project_not_found", "The launched project could not be read from the machine project list.");
155
+ }
156
+ return {
157
+ project,
158
+ join_url: ready.join_url,
159
+ ...(ready.join_token_expires_at === undefined
160
+ ? {}
161
+ : { join_token_expires_at: ready.join_token_expires_at }),
162
+ };
163
+ }
164
+ catch (error) {
165
+ throw this.#normalizeError(error);
166
+ }
167
+ }
168
+ async refreshInvite(projectId) {
169
+ const project = resolveManagedProjectContext({
170
+ target: projectId,
171
+ cwd: this.#cwd,
172
+ env: this.#env,
173
+ });
174
+ if (project.runtime_endpoint === null) {
175
+ throw new LocalConsoleProjectError("host_not_running", "Launch this project before creating an invite link.");
176
+ }
177
+ try {
178
+ const status = await rotateHostLocalInvite({ endpoint: project.runtime_endpoint });
179
+ const joinUrl = status.relay?.join_url;
180
+ if (joinUrl === undefined) {
181
+ throw new LocalConsoleProjectError("relay_registration_failed", "Relay did not return a visible invite link.");
182
+ }
183
+ return {
184
+ join_url: joinUrl,
185
+ ...(status.relay?.join_token_expires_at === undefined
186
+ ? {}
187
+ : { expires_at: status.relay.join_token_expires_at }),
188
+ };
189
+ }
190
+ catch (error) {
191
+ throw this.#normalizeError(error);
192
+ }
193
+ }
194
+ async stopProject(projectId) {
195
+ try {
196
+ return {
197
+ message: await runStopCommand(projectId, {
198
+ cwd: this.#cwd,
199
+ env: this.#env,
200
+ }),
201
+ };
202
+ }
203
+ catch (error) {
204
+ throw this.#normalizeError(error);
205
+ }
206
+ }
207
+ #normalizeError(error) {
208
+ if (error instanceof LocalConsoleProjectError) {
209
+ return error;
210
+ }
211
+ if (error instanceof LaunchError) {
212
+ return new LocalConsoleProjectError(error.code, error.message);
213
+ }
214
+ return new LocalConsoleProjectError("local_console_failed", formatCliErrorReason(error), 500);
215
+ }
216
+ }
217
+ //# sourceMappingURL=project-service.js.map
@@ -0,0 +1,21 @@
1
+ export type LocalConsoleRuntimeEndpoint = {
2
+ schema_version: 1;
3
+ pid: number;
4
+ base_url: string;
5
+ control_token: string;
6
+ started_at: string;
7
+ };
8
+ export declare function createLocalConsoleSecret(): string;
9
+ export declare function readLocalConsoleRuntimeEndpoint(tuttiHome: string): LocalConsoleRuntimeEndpoint | null;
10
+ export declare function writeLocalConsoleRuntimeEndpoint(options: {
11
+ tuttiHome: string;
12
+ pid: number;
13
+ baseUrl: string;
14
+ controlToken: string;
15
+ now?: () => Date;
16
+ }): LocalConsoleRuntimeEndpoint;
17
+ export declare function deleteLocalConsoleRuntimeEndpoint(options: {
18
+ tuttiHome: string;
19
+ expectedControlToken?: string;
20
+ }): void;
21
+ //# sourceMappingURL=runtime-endpoint.d.ts.map
@@ -0,0 +1,74 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ function endpointPath(tuttiHome) {
5
+ return join(tuttiHome, "runtime", "local-console.json");
6
+ }
7
+ function isRecord(value) {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value);
9
+ }
10
+ function isEndpoint(value) {
11
+ return (isRecord(value) &&
12
+ value.schema_version === 1 &&
13
+ typeof value.pid === "number" &&
14
+ Number.isInteger(value.pid) &&
15
+ typeof value.base_url === "string" &&
16
+ /^http:\/\/127\.0\.0\.1:\d+$/u.test(value.base_url) &&
17
+ typeof value.control_token === "string" &&
18
+ value.control_token.length >= 32 &&
19
+ typeof value.started_at === "string");
20
+ }
21
+ export function createLocalConsoleSecret() {
22
+ return randomBytes(32).toString("base64url");
23
+ }
24
+ export function readLocalConsoleRuntimeEndpoint(tuttiHome) {
25
+ const path = endpointPath(tuttiHome);
26
+ if (!existsSync(path)) {
27
+ return null;
28
+ }
29
+ try {
30
+ const value = JSON.parse(readFileSync(path, "utf8"));
31
+ return isEndpoint(value) ? value : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ export function writeLocalConsoleRuntimeEndpoint(options) {
38
+ const record = {
39
+ schema_version: 1,
40
+ pid: options.pid,
41
+ base_url: options.baseUrl,
42
+ control_token: options.controlToken,
43
+ started_at: (options.now ?? (() => new Date()))().toISOString(),
44
+ };
45
+ if (!isEndpoint(record)) {
46
+ throw new Error("Local Console runtime endpoint is invalid.");
47
+ }
48
+ const path = endpointPath(options.tuttiHome);
49
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
50
+ chmodSync(dirname(path), 0o700);
51
+ const temporaryPath = `${path}.${process.pid}.tmp`;
52
+ writeFileSync(temporaryPath, `${JSON.stringify(record, null, 2)}\n`, {
53
+ encoding: "utf8",
54
+ mode: 0o600,
55
+ });
56
+ chmodSync(temporaryPath, 0o600);
57
+ renameSync(temporaryPath, path);
58
+ chmodSync(path, 0o600);
59
+ return record;
60
+ }
61
+ export function deleteLocalConsoleRuntimeEndpoint(options) {
62
+ const path = endpointPath(options.tuttiHome);
63
+ if (!existsSync(path)) {
64
+ return;
65
+ }
66
+ if (options.expectedControlToken !== undefined) {
67
+ const current = readLocalConsoleRuntimeEndpoint(options.tuttiHome);
68
+ if (current === null || current.control_token !== options.expectedControlToken) {
69
+ return;
70
+ }
71
+ }
72
+ unlinkSync(path);
73
+ }
74
+ //# sourceMappingURL=runtime-endpoint.js.map
@@ -0,0 +1,17 @@
1
+ import { type FastifyInstance } from "fastify";
2
+ export declare const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
3
+ type LocalConsoleServerOptions = {
4
+ controlToken: string;
5
+ cwd: string;
6
+ env?: NodeJS.ProcessEnv;
7
+ now?: () => Date;
8
+ webStaticRoot?: string | false;
9
+ };
10
+ export declare function createLocalConsoleServer(options: LocalConsoleServerOptions): FastifyInstance;
11
+ export declare function runLocalConsoleProcess(options?: {
12
+ cwd?: string;
13
+ env?: NodeJS.ProcessEnv;
14
+ now?: () => Date;
15
+ }): Promise<void>;
16
+ export {};
17
+ //# sourceMappingURL=server.d.ts.map
@@ -0,0 +1,301 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import fastify from "fastify";
4
+ import { resolveTuttiHome } from "../../providers/openai/index.js";
5
+ import { registerHostWebStaticRoutes, resolvePackagedWebStaticRoot } from "../http/static-web.js";
6
+ import { createLocalProjectFolder, detectLocalFolderPicker, pickLocalFolder, } from "./folder-picker.js";
7
+ import { LocalConsoleProjectError, LocalConsoleProjectService, } from "./project-service.js";
8
+ import { LOCAL_CONSOLE_SESSION_COOKIE, LocalConsoleSessionRegistry, localConsoleSessionCookie, parseCookieHeader, } from "./session.js";
9
+ import { createLocalConsoleSecret, deleteLocalConsoleRuntimeEndpoint, writeLocalConsoleRuntimeEndpoint, } from "./runtime-endpoint.js";
10
+ export const LOCAL_CONSOLE_API_BASE = "/local-console/v1";
11
+ class LocalConsoleHttpError extends Error {
12
+ statusCode;
13
+ code;
14
+ constructor(statusCode, code, message) {
15
+ super(message);
16
+ this.statusCode = statusCode;
17
+ this.code = code;
18
+ this.name = "LocalConsoleHttpError";
19
+ }
20
+ }
21
+ function isValidationError(error) {
22
+ return (typeof error === "object" &&
23
+ error !== null &&
24
+ "validation" in error &&
25
+ Array.isArray(error.validation));
26
+ }
27
+ function headerValue(value) {
28
+ return typeof value === "string" || value === undefined ? value : value[0];
29
+ }
30
+ function bearerToken(value) {
31
+ const header = headerValue(value);
32
+ const match = header === undefined ? null : /^Bearer\s+(.+)$/iu.exec(header.trim());
33
+ return match?.[1] ?? null;
34
+ }
35
+ function tokenEquals(value, expected) {
36
+ if (value === null || value.length === 0 || expected.length === 0) {
37
+ return false;
38
+ }
39
+ const actualBuffer = Buffer.from(value);
40
+ const expectedBuffer = Buffer.from(expected);
41
+ return (actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer));
42
+ }
43
+ function requestOrigin(request) {
44
+ return `http://${headerValue(request.headers.host) ?? ""}`;
45
+ }
46
+ function requireSameOrigin(request) {
47
+ const origin = headerValue(request.headers.origin);
48
+ const fetchSite = headerValue(request.headers["sec-fetch-site"]);
49
+ if (origin !== requestOrigin(request) ||
50
+ (fetchSite !== undefined && fetchSite !== "same-origin" && fetchSite !== "none")) {
51
+ throw new LocalConsoleHttpError(403, "origin_rejected", "Local Console origin was rejected.");
52
+ }
53
+ }
54
+ function requireLoopbackHost(request) {
55
+ const host = headerValue(request.headers.host) ?? "";
56
+ if (!/^127\.0\.0\.1:\d+$/u.test(host)) {
57
+ throw new LocalConsoleHttpError(400, "host_rejected", "Local Console requires 127.0.0.1.");
58
+ }
59
+ }
60
+ export function createLocalConsoleServer(options) {
61
+ const app = fastify({ logger: false, bodyLimit: 64 * 1024 });
62
+ const sessions = new LocalConsoleSessionRegistry({
63
+ ...(options.now === undefined ? {} : { now: options.now }),
64
+ });
65
+ const projects = new LocalConsoleProjectService({
66
+ cwd: options.cwd,
67
+ ...(options.env === undefined ? {} : { env: options.env }),
68
+ });
69
+ app.addHook("onRequest", (request, reply, done) => {
70
+ try {
71
+ requireLoopbackHost(request);
72
+ void reply
73
+ .header("x-content-type-options", "nosniff")
74
+ .header("x-frame-options", "DENY")
75
+ .header("referrer-policy", "no-referrer")
76
+ .header("content-security-policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
77
+ if (request.url.startsWith(LOCAL_CONSOLE_API_BASE)) {
78
+ void reply.header("cache-control", "no-store");
79
+ }
80
+ done();
81
+ }
82
+ catch (error) {
83
+ done(error);
84
+ }
85
+ });
86
+ app.setErrorHandler((error, _request, reply) => {
87
+ const normalized = error instanceof LocalConsoleHttpError || error instanceof LocalConsoleProjectError
88
+ ? error
89
+ : isValidationError(error)
90
+ ? new LocalConsoleHttpError(400, "bad_request", "Local Console request is invalid.")
91
+ : new LocalConsoleHttpError(500, "local_console_failed", "Local Console request failed.");
92
+ void reply.status(normalized.statusCode).send({
93
+ error: {
94
+ code: normalized.code,
95
+ message: normalized.message,
96
+ retryable: normalized.statusCode >= 500,
97
+ },
98
+ });
99
+ });
100
+ function requireControl(request) {
101
+ if (!tokenEquals(bearerToken(request.headers.authorization), options.controlToken)) {
102
+ throw new LocalConsoleHttpError(401, "unauthorized", "Local Console control token is invalid.");
103
+ }
104
+ }
105
+ function requireSession(request, csrf = false) {
106
+ const cookie = parseCookieHeader(headerValue(request.headers.cookie));
107
+ const session = sessions.readSession(cookie[LOCAL_CONSOLE_SESSION_COOKIE]);
108
+ if (session === null) {
109
+ throw new LocalConsoleHttpError(401, "session_required", "Open Tutti from the CLI again.");
110
+ }
111
+ if (csrf) {
112
+ requireSameOrigin(request);
113
+ const csrfToken = headerValue(request.headers["x-local-console-csrf"]);
114
+ if (!tokenEquals(csrfToken ?? null, session.csrfToken)) {
115
+ throw new LocalConsoleHttpError(403, "csrf_rejected", "Local Console request was rejected.");
116
+ }
117
+ }
118
+ return session;
119
+ }
120
+ app.get(`${LOCAL_CONSOLE_API_BASE}/health`, () => ({ status: "ready" }));
121
+ app.post(`${LOCAL_CONSOLE_API_BASE}/access-tokens`, {
122
+ schema: {
123
+ body: {
124
+ type: "object",
125
+ required: ["current_directory"],
126
+ additionalProperties: false,
127
+ properties: { current_directory: { type: "string", minLength: 1, maxLength: 4096 } },
128
+ },
129
+ },
130
+ }, (request) => {
131
+ requireControl(request);
132
+ const access = sessions.issueAccessToken({
133
+ currentDirectory: resolve(request.body.current_directory),
134
+ });
135
+ return { access_token: access.token, expires_at: access.expires_at };
136
+ });
137
+ app.post(`${LOCAL_CONSOLE_API_BASE}/session`, {
138
+ schema: {
139
+ body: {
140
+ type: "object",
141
+ required: ["access_token"],
142
+ additionalProperties: false,
143
+ properties: { access_token: { type: "string", minLength: 1 } },
144
+ },
145
+ },
146
+ }, (request, reply) => {
147
+ requireSameOrigin(request);
148
+ const session = sessions.exchangeAccessToken(request.body.access_token);
149
+ if (session === null) {
150
+ throw new LocalConsoleHttpError(401, "access_token_invalid", "This Local Console access link has expired. Run `tutti` again.");
151
+ }
152
+ void reply.header("set-cookie", localConsoleSessionCookie(session.sessionToken, session.expires_at));
153
+ return { csrf_token: session.csrfToken, expires_at: session.expires_at };
154
+ });
155
+ app.get(`${LOCAL_CONSOLE_API_BASE}/bootstrap`, (request) => {
156
+ const session = requireSession(request);
157
+ const picker = detectLocalFolderPicker(process.platform, options.env ?? process.env);
158
+ return {
159
+ csrf_token: session.csrfToken,
160
+ current_directory: session.currentDirectory,
161
+ platform: process.platform,
162
+ folder_picker: picker.kind,
163
+ };
164
+ });
165
+ app.get(`${LOCAL_CONSOLE_API_BASE}/projects`, async (request) => {
166
+ requireSession(request);
167
+ return { projects: await projects.listProjects() };
168
+ });
169
+ app.post(`${LOCAL_CONSOLE_API_BASE}/folders/pick`, async (request) => {
170
+ requireSession(request, true);
171
+ return await pickLocalFolder({ env: options.env ?? process.env });
172
+ });
173
+ app.post(`${LOCAL_CONSOLE_API_BASE}/folders/create`, {
174
+ schema: {
175
+ body: {
176
+ type: "object",
177
+ required: ["parent_path", "name"],
178
+ additionalProperties: false,
179
+ properties: {
180
+ parent_path: { type: "string", minLength: 1 },
181
+ name: { type: "string", minLength: 1, maxLength: 160 },
182
+ },
183
+ },
184
+ },
185
+ }, (request) => {
186
+ requireSession(request, true);
187
+ try {
188
+ return { path: createLocalProjectFolder(request.body.parent_path, request.body.name) };
189
+ }
190
+ catch (error) {
191
+ throw new LocalConsoleHttpError(422, "project_folder_create_failed", error instanceof Error ? error.message : "Project folder could not be created.");
192
+ }
193
+ });
194
+ app.post(`${LOCAL_CONSOLE_API_BASE}/providers/models`, {
195
+ schema: {
196
+ body: {
197
+ type: "object",
198
+ required: ["base_url", "api_key"],
199
+ additionalProperties: false,
200
+ properties: {
201
+ base_url: { type: "string", minLength: 1, maxLength: 2048 },
202
+ api_key: { type: "string", minLength: 1, maxLength: 4096 },
203
+ },
204
+ },
205
+ },
206
+ }, async (request) => {
207
+ requireSession(request, true);
208
+ return await projects.discoverModels({
209
+ baseUrl: request.body.base_url,
210
+ apiKey: request.body.api_key,
211
+ });
212
+ });
213
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/launch`, {
214
+ schema: {
215
+ body: {
216
+ type: "object",
217
+ required: ["workspace_path"],
218
+ additionalProperties: false,
219
+ properties: {
220
+ workspace_path: { type: "string", minLength: 1, maxLength: 4096 },
221
+ provider: {
222
+ type: "object",
223
+ required: ["base_url", "api_key", "model"],
224
+ additionalProperties: false,
225
+ properties: {
226
+ base_url: { type: "string", minLength: 1, maxLength: 2048 },
227
+ api_key: { type: "string", minLength: 1, maxLength: 4096 },
228
+ model: { type: "string", minLength: 1, maxLength: 240 },
229
+ },
230
+ },
231
+ },
232
+ },
233
+ },
234
+ }, async (request) => {
235
+ requireSession(request, true);
236
+ return await projects.launchProject({
237
+ workspacePath: request.body.workspace_path,
238
+ ...(request.body.provider === undefined ? {} : { provider: request.body.provider }),
239
+ });
240
+ });
241
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/invite`, async (request) => {
242
+ requireSession(request, true);
243
+ return await projects.refreshInvite(request.params.projectId);
244
+ });
245
+ app.post(`${LOCAL_CONSOLE_API_BASE}/projects/:projectId/stop`, async (request) => {
246
+ requireSession(request, true);
247
+ return await projects.stopProject(request.params.projectId);
248
+ });
249
+ const webStaticRoot = options.webStaticRoot === false
250
+ ? undefined
251
+ : (options.webStaticRoot ?? resolvePackagedWebStaticRoot(options.env));
252
+ if (webStaticRoot !== undefined) {
253
+ registerHostWebStaticRoutes(app, { root: webStaticRoot });
254
+ }
255
+ return app;
256
+ }
257
+ function listeningPort(app) {
258
+ const address = app.server.address();
259
+ if (typeof address !== "object" || address === null) {
260
+ throw new Error("Local Console did not expose a loopback port.");
261
+ }
262
+ return address.port;
263
+ }
264
+ export async function runLocalConsoleProcess(options = {}) {
265
+ const cwd = resolve(options.cwd ?? process.cwd());
266
+ const env = options.env ?? process.env;
267
+ const tuttiHome = resolveTuttiHome(env.TUTTI_HOME, cwd);
268
+ const controlToken = createLocalConsoleSecret();
269
+ const app = createLocalConsoleServer({
270
+ controlToken,
271
+ cwd,
272
+ env,
273
+ ...(options.now === undefined ? {} : { now: options.now }),
274
+ });
275
+ await app.listen({ host: "127.0.0.1", port: 0 });
276
+ writeLocalConsoleRuntimeEndpoint({
277
+ tuttiHome,
278
+ pid: process.pid,
279
+ baseUrl: `http://127.0.0.1:${listeningPort(app)}`,
280
+ controlToken,
281
+ ...(options.now === undefined ? {} : { now: options.now }),
282
+ });
283
+ let closing = false;
284
+ const close = async () => {
285
+ if (closing) {
286
+ return;
287
+ }
288
+ closing = true;
289
+ deleteLocalConsoleRuntimeEndpoint({
290
+ tuttiHome,
291
+ expectedControlToken: controlToken,
292
+ });
293
+ await app.close();
294
+ };
295
+ process.once("SIGINT", () => void close());
296
+ process.once("SIGTERM", () => void close());
297
+ await new Promise((resolveClosed) => {
298
+ app.server.once("close", resolveClosed);
299
+ });
300
+ }
301
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,33 @@
1
+ export declare const LOCAL_CONSOLE_SESSION_COOKIE = "tutti_local_console_session";
2
+ type ExpiringValue = {
3
+ hash: string;
4
+ expiresAt: number;
5
+ };
6
+ type LocalConsoleSession = ExpiringValue & {
7
+ csrfToken: string;
8
+ currentDirectory: string;
9
+ };
10
+ export type IssuedLocalConsoleAccessToken = {
11
+ token: string;
12
+ expires_at: string;
13
+ };
14
+ export type ExchangedLocalConsoleSession = {
15
+ sessionToken: string;
16
+ csrfToken: string;
17
+ expires_at: string;
18
+ };
19
+ export declare class LocalConsoleSessionRegistry {
20
+ #private;
21
+ constructor(options?: {
22
+ now?: () => Date;
23
+ });
24
+ issueAccessToken(options: {
25
+ currentDirectory: string;
26
+ }): IssuedLocalConsoleAccessToken;
27
+ exchangeAccessToken(token: string): ExchangedLocalConsoleSession | null;
28
+ readSession(token: string | undefined): LocalConsoleSession | null;
29
+ }
30
+ export declare function parseCookieHeader(header: string | undefined): Record<string, string>;
31
+ export declare function localConsoleSessionCookie(token: string, expiresAt: string): string;
32
+ export {};
33
+ //# sourceMappingURL=session.d.ts.map