@xfey/tutti 0.1.53 → 0.1.55
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 +5 -2
- package/desktop-assets/README.md +13 -0
- package/desktop-assets/Tutti-square.png +0 -0
- package/desktop-assets/Tutti.icns +0 -0
- package/desktop-assets/macos-handler.applescript +24 -0
- package/dist/artifacts/index.d.ts +3 -3
- package/dist/artifacts/index.js +3 -3
- package/dist/artifacts/live-manifest.d.ts +23 -0
- package/dist/artifacts/live-manifest.js +115 -0
- package/dist/artifacts/preview-runtime.d.ts +34 -36
- package/dist/artifacts/preview-runtime.js +215 -273
- package/dist/artifacts/static-preview.d.ts +2 -1
- package/dist/artifacts/static-preview.js +19 -21
- package/dist/control-plane/index.d.ts +1 -0
- package/dist/control-plane/index.js +13 -2
- package/dist/control-plane/types.d.ts +4 -0
- package/dist/server-shell/cli/args.d.ts +7 -0
- package/dist/server-shell/cli/args.js +32 -0
- package/dist/server-shell/cli/cli.js +76 -1
- package/dist/server-shell/cli/errors.d.ts +1 -1
- package/dist/server-shell/cli/host-server-runtime.js +20 -0
- package/dist/server-shell/cli/version.d.ts +6 -0
- package/dist/server-shell/cli/version.js +11 -2
- package/dist/server-shell/desktop-integration/index.d.ts +5 -0
- package/dist/server-shell/desktop-integration/index.js +5 -0
- package/dist/server-shell/desktop-integration/linux.d.ts +12 -0
- package/dist/server-shell/desktop-integration/linux.js +186 -0
- package/dist/server-shell/desktop-integration/macos.d.ts +9 -0
- package/dist/server-shell/desktop-integration/macos.js +138 -0
- package/dist/server-shell/desktop-integration/manager.d.ts +40 -0
- package/dist/server-shell/desktop-integration/manager.js +223 -0
- package/dist/server-shell/desktop-integration/platform.d.ts +23 -0
- package/dist/server-shell/desktop-integration/platform.js +21 -0
- package/dist/server-shell/desktop-integration/protocol.d.ts +9 -0
- package/dist/server-shell/desktop-integration/protocol.js +28 -0
- package/dist/server-shell/desktop-integration/record.d.ts +17 -0
- package/dist/server-shell/desktop-integration/record.js +74 -0
- package/dist/server-shell/http/routes/project-api/artifacts-routes.js +43 -110
- package/dist/server-shell/http/routes/project-api/openapi-artifacts-routes.d.ts +5 -8
- package/dist/server-shell/http/routes/project-api/openapi-artifacts-routes.js +5 -33
- package/dist/server-shell/http/routes/project-api/openapi.d.ts +17 -8
- package/dist/server-shell/http/routes/project-api/types.d.ts +3 -2
- package/dist/server-shell/local-console/invocation-context.d.ts +7 -0
- package/dist/server-shell/local-console/invocation-context.js +8 -9
- package/dist/server-shell/local-console/managed-console.d.ts +1 -0
- package/dist/server-shell/local-console/managed-console.js +10 -5
- package/dist/server-shell/local-console/project-service.js +1 -1
- package/dist/server-shell/local-console/server.js +23 -6
- package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +7 -88
- package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +3 -48
- package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
- package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +1 -10
- package/package.json +2 -1
- package/web/assets/{homepage-motion-scene-F4ibPvI9.js → homepage-motion-scene-D0-wLq3D.js} +1 -1
- package/web/assets/{index-B1mBtL9x.js → index-BAsLSZ5U.js} +14 -14
- package/web/assets/index-DMaiDZ9k.css +1 -0
- package/web/assets/tutti_avatar-_0Yc-cMJ.png +0 -0
- package/web/index.html +2 -2
- package/dist/artifacts/manifest.d.ts +0 -41
- package/dist/artifacts/manifest.js +0 -169
- package/web/assets/index-OFlcQ3eS.css +0 -1
- package/web/assets/tutti_avatar-BBhaZGi3.png +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, posix, relative, resolve } from "node:path";
|
|
2
3
|
import { VIEWER_HARD_READ_LIMIT_BYTES } from "../workspace-ops/constants.js";
|
|
3
4
|
import { WorkspaceOpsError } from "../workspace-ops/errors.js";
|
|
4
|
-
import { entryKind, lsPath, readBlob, requireFileEntry } from "../workspace-ops/viewer-git.js";
|
|
5
5
|
import { requireNormalizedViewerPath } from "../workspace-ops/viewer-paths.js";
|
|
6
|
-
import {
|
|
6
|
+
import { readLiveArtifactDeclaration, } from "./live-manifest.js";
|
|
7
7
|
export const ARTIFACT_PREVIEW_CSP = [
|
|
8
8
|
"default-src 'none'",
|
|
9
9
|
"script-src 'self' https: 'unsafe-inline' 'unsafe-eval' blob:",
|
|
@@ -139,10 +139,7 @@ function requireDeclaredStaticArtifact(declaration) {
|
|
|
139
139
|
return declaration;
|
|
140
140
|
}
|
|
141
141
|
export function readArtifactStaticPreview(options) {
|
|
142
|
-
const declaration = requireDeclaredStaticArtifact(
|
|
143
|
-
workspaceRoot: options.workspaceRoot,
|
|
144
|
-
...(options.now === undefined ? {} : { now: options.now }),
|
|
145
|
-
}));
|
|
142
|
+
const declaration = requireDeclaredStaticArtifact(options.declaration ?? readLiveArtifactDeclaration({ workspaceRoot: options.workspaceRoot }));
|
|
146
143
|
const runtime = declaration.runtime;
|
|
147
144
|
if (runtime.kind !== "static") {
|
|
148
145
|
throw new WorkspaceOpsError("validation_failed", "Artifact is not static");
|
|
@@ -150,30 +147,31 @@ export function readArtifactStaticPreview(options) {
|
|
|
150
147
|
const requestedPath = requireSafeArtifactAssetPath(decodePath(options.path));
|
|
151
148
|
const relativePath = requestedPath === "" ? runtime.entry_path : requestedPath;
|
|
152
149
|
const repoPath = runtime.root_path === "" ? relativePath : posix.join(runtime.root_path, relativePath);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
let filePath;
|
|
151
|
+
let entrySize;
|
|
152
|
+
try {
|
|
153
|
+
const workspaceRealPath = realpathSync.native(options.workspaceRoot);
|
|
154
|
+
filePath = realpathSync.native(resolve(workspaceRealPath, repoPath));
|
|
155
|
+
const relativePath = relative(workspaceRealPath, filePath);
|
|
156
|
+
if ((relativePath !== "" && (relativePath.startsWith("..") || isAbsolute(relativePath))) ||
|
|
157
|
+
!lstatSync(filePath).isFile()) {
|
|
158
|
+
throw new Error("Artifact preview path is not a contained file");
|
|
159
|
+
}
|
|
160
|
+
entrySize = lstatSync(filePath).size;
|
|
159
161
|
}
|
|
160
|
-
|
|
161
|
-
throw new WorkspaceOpsError("
|
|
162
|
+
catch {
|
|
163
|
+
throw new WorkspaceOpsError("not_found", "Artifact preview file was not found");
|
|
162
164
|
}
|
|
163
|
-
if (
|
|
165
|
+
if (entrySize > VIEWER_HARD_READ_LIMIT_BYTES) {
|
|
164
166
|
throw new WorkspaceOpsError("validation_failed", "Artifact preview file is too large");
|
|
165
167
|
}
|
|
166
|
-
const fileEntry = requireFileEntry({
|
|
167
|
-
workspaceRoot: options.workspaceRoot,
|
|
168
|
-
path: repoPath,
|
|
169
|
-
});
|
|
170
168
|
const mediaType = mediaTypeFor(repoPath);
|
|
171
169
|
return {
|
|
172
170
|
media_type: mediaType,
|
|
173
171
|
content: rewriteArtifactPreviewResourceUrls({
|
|
174
172
|
mediaType,
|
|
175
173
|
resourcePath: relativePath,
|
|
176
|
-
content:
|
|
174
|
+
content: readFileSync(filePath),
|
|
177
175
|
}),
|
|
178
176
|
};
|
|
179
177
|
}
|
|
@@ -15,6 +15,7 @@ export declare class Phase5ControlPlane {
|
|
|
15
15
|
private readonly readActiveApproval;
|
|
16
16
|
private readonly logger;
|
|
17
17
|
private readonly projectContext;
|
|
18
|
+
private readonly runLifecycle;
|
|
18
19
|
private readonly now;
|
|
19
20
|
private readonly scratchpadAutoRefreshScheduler;
|
|
20
21
|
private lastRecoveryConclusion;
|
|
@@ -27,6 +27,7 @@ export class Phase5ControlPlane {
|
|
|
27
27
|
readActiveApproval;
|
|
28
28
|
logger;
|
|
29
29
|
projectContext;
|
|
30
|
+
runLifecycle;
|
|
30
31
|
now;
|
|
31
32
|
scratchpadAutoRefreshScheduler;
|
|
32
33
|
lastRecoveryConclusion = null;
|
|
@@ -39,6 +40,7 @@ export class Phase5ControlPlane {
|
|
|
39
40
|
this.readActiveApproval = options.readActiveApproval;
|
|
40
41
|
this.logger = options.logger;
|
|
41
42
|
this.projectContext = options.projectContext;
|
|
43
|
+
this.runLifecycle = options.runLifecycle;
|
|
42
44
|
this.now = options.now ?? (() => new Date());
|
|
43
45
|
this.engine = new ProcedureEngine({ now: this.now });
|
|
44
46
|
this.runEngine = new RunEngine({ now: this.now });
|
|
@@ -203,7 +205,7 @@ export class Phase5ControlPlane {
|
|
|
203
205
|
}
|
|
204
206
|
}
|
|
205
207
|
startRunForTask(input) {
|
|
206
|
-
|
|
208
|
+
const result = startControlPlaneRunForTask({
|
|
207
209
|
runEngine: this.runEngine,
|
|
208
210
|
store: this.store,
|
|
209
211
|
run: input,
|
|
@@ -211,10 +213,19 @@ export class Phase5ControlPlane {
|
|
|
211
213
|
resolveWorkflowRunner: this.resolveWorkflowRunner,
|
|
212
214
|
logger: this.logger,
|
|
213
215
|
now: this.now,
|
|
214
|
-
publishRunTransition: (state) =>
|
|
216
|
+
publishRunTransition: (state) => {
|
|
217
|
+
this.publishRunTransition(state);
|
|
218
|
+
if (state.transition_kind === "terminal") {
|
|
219
|
+
this.runLifecycle?.settled();
|
|
220
|
+
}
|
|
221
|
+
},
|
|
215
222
|
publishTaskRunStarted: (task, activityRef) => this.publishTaskRunStarted(task, activityRef),
|
|
216
223
|
publishRunResultRecorded: (recorded) => this.publishRunResultRecorded(recorded),
|
|
217
224
|
});
|
|
225
|
+
if (result.kind === "accepted") {
|
|
226
|
+
this.runLifecycle?.started();
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
218
229
|
}
|
|
219
230
|
readActiveRun() {
|
|
220
231
|
const runStatus = this.runEngine.getExecutionStatus();
|
|
@@ -19,6 +19,10 @@ export type Phase5ControlPlaneOptions = {
|
|
|
19
19
|
workspaceRoot: string;
|
|
20
20
|
projectName: string;
|
|
21
21
|
};
|
|
22
|
+
runLifecycle?: {
|
|
23
|
+
started: () => void;
|
|
24
|
+
settled: () => void;
|
|
25
|
+
};
|
|
22
26
|
now?: () => Date;
|
|
23
27
|
};
|
|
24
28
|
export type ControlPlaneLogger = Pick<RuntimeLogger, "info">;
|
|
@@ -5,6 +5,9 @@ export type CliCommand = {
|
|
|
5
5
|
topic?: string;
|
|
6
6
|
} | {
|
|
7
7
|
kind: "version";
|
|
8
|
+
} | {
|
|
9
|
+
kind: "desktop";
|
|
10
|
+
action: "install" | "status" | "remove";
|
|
8
11
|
} | {
|
|
9
12
|
kind: "service";
|
|
10
13
|
action: "status" | "stop" | "restart";
|
|
@@ -22,6 +25,9 @@ export type CliCommand = {
|
|
|
22
25
|
yes: boolean;
|
|
23
26
|
} | {
|
|
24
27
|
kind: "internal-console-run";
|
|
28
|
+
} | {
|
|
29
|
+
kind: "internal-desktop-open";
|
|
30
|
+
url?: string;
|
|
25
31
|
} | {
|
|
26
32
|
kind: "ps";
|
|
27
33
|
manage: boolean;
|
|
@@ -59,6 +65,7 @@ export type LaunchCliArgs = {
|
|
|
59
65
|
target?: string;
|
|
60
66
|
yes: boolean;
|
|
61
67
|
};
|
|
68
|
+
export declare function shouldEnsureDesktopIntegrationAutomatically(command: CliCommand): boolean;
|
|
62
69
|
export declare function parseLaunchCliArgs(argv: readonly string[]): LaunchCliArgs;
|
|
63
70
|
export declare function parseCliArgs(argv: readonly string[], cwd?: string): CliCommand;
|
|
64
71
|
//# sourceMappingURL=args.d.ts.map
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { LaunchError } from "./errors.js";
|
|
3
|
+
export function shouldEnsureDesktopIntegrationAutomatically(command) {
|
|
4
|
+
return command.kind === "console";
|
|
5
|
+
}
|
|
3
6
|
function isHelpArg(arg) {
|
|
4
7
|
return arg === "--help" || arg === "-h";
|
|
5
8
|
}
|
|
@@ -58,6 +61,25 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
58
61
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
59
62
|
return { kind: "version" };
|
|
60
63
|
}
|
|
64
|
+
if (command === "desktop") {
|
|
65
|
+
const action = args.shift();
|
|
66
|
+
if (action === undefined) {
|
|
67
|
+
return { kind: "desktop", action: "install" };
|
|
68
|
+
}
|
|
69
|
+
if (isHelpArg(action)) {
|
|
70
|
+
return { kind: "help", topic: "desktop" };
|
|
71
|
+
}
|
|
72
|
+
if (args.length > 0) {
|
|
73
|
+
if (hasHelpArg(args)) {
|
|
74
|
+
return { kind: "help", topic: "desktop" };
|
|
75
|
+
}
|
|
76
|
+
throw new LaunchError("desktop_integration_unavailable", `Unsupported desktop option: ${args[0] ?? ""}`, "Run `tutti desktop --help` to see supported desktop commands.");
|
|
77
|
+
}
|
|
78
|
+
if (action !== "status" && action !== "remove") {
|
|
79
|
+
throw new LaunchError("desktop_integration_unavailable", `Unsupported desktop command: ${action}`, "Run `tutti desktop --help` to see supported desktop commands.");
|
|
80
|
+
}
|
|
81
|
+
return { kind: "desktop", action };
|
|
82
|
+
}
|
|
61
83
|
if (command === "service") {
|
|
62
84
|
const action = args.shift();
|
|
63
85
|
if (action === undefined || isHelpArg(action)) {
|
|
@@ -122,6 +144,16 @@ export function parseCliArgs(argv, cwd = process.cwd()) {
|
|
|
122
144
|
}
|
|
123
145
|
return { kind: "internal-console-run" };
|
|
124
146
|
}
|
|
147
|
+
if (internalCommand === "desktop-open") {
|
|
148
|
+
if (args.length > 1) {
|
|
149
|
+
throw new LaunchError("desktop_protocol_invalid", "Internal desktop-open accepts only one URL", "Desktop protocol requests are managed by Tutti.");
|
|
150
|
+
}
|
|
151
|
+
const url = args[0];
|
|
152
|
+
return {
|
|
153
|
+
kind: "internal-desktop-open",
|
|
154
|
+
...(url === undefined ? {} : { url }),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
125
157
|
if (internalCommand !== "host-run") {
|
|
126
158
|
throw new LaunchError("git_bootstrap_failed", "Unsupported internal command", "Internal commands are managed by Tutti.");
|
|
127
159
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
2
3
|
import { formatCliError, formatCliErrorReason, LaunchError } from "./errors.js";
|
|
3
|
-
import { parseCliArgs } from "./args.js";
|
|
4
|
+
import { parseCliArgs, shouldEnsureDesktopIntegrationAutomatically } from "./args.js";
|
|
4
5
|
import { confirmTextFromTty, runBackgroundLaunchCommand, runForegroundLaunchCommand, runInternalHostRunCommand, } from "./launch-command.js";
|
|
5
6
|
import { resolveExistingProjectContext } from "./project-resolver.js";
|
|
6
7
|
import { runProviderSetupTui } from "./provider-tui.js";
|
|
7
8
|
import { runArchiveCommand, runDoctorCommand, runForgetCommand, runInviteCommand, runLogsCommand, runProviderStatusCommand, runPsManageCommand, runPsCommand, runStopCommand, listRuntimeProjects, } from "./runtime-commands.js";
|
|
8
9
|
import { readCliVersion } from "./version.js";
|
|
10
|
+
import { ensureDesktopIntegrationAutomatically, parseTuttiDesktopUrl, runDesktopIntegrationCommand, TUTTI_DESKTOP_PROJECTS_URL, TuttiDesktopProtocolError, } from "../desktop-integration/index.js";
|
|
9
11
|
import { ensureLocalConsole, openLocalConsoleBrowser, readLocalConsoleServiceStatus, restartLocalConsoleService, runLocalConsoleProcess, stopLocalConsoleService, } from "../local-console/index.js";
|
|
10
12
|
const HELP_TEXT = `Usage:
|
|
11
13
|
tutti
|
|
12
14
|
tutti launch [target] [--yes] [--foreground]
|
|
15
|
+
tutti desktop [status|remove]
|
|
13
16
|
tutti service <status|stop|restart>
|
|
14
17
|
tutti ps [--manage] [--all]
|
|
15
18
|
tutti invite [target]
|
|
@@ -61,6 +64,16 @@ Commands:
|
|
|
61
64
|
stop Stop only the local browser service; project hosts keep running.
|
|
62
65
|
restart Replace the local browser service with the current CLI version.
|
|
63
66
|
`;
|
|
67
|
+
const DESKTOP_HELP_TEXT = `Usage:
|
|
68
|
+
tutti desktop
|
|
69
|
+
tutti desktop status
|
|
70
|
+
tutti desktop remove
|
|
71
|
+
|
|
72
|
+
Commands:
|
|
73
|
+
desktop Install or repair the user-level Tutti app and tutti:// handler.
|
|
74
|
+
desktop status Inspect the managed desktop entry without starting Tutti.
|
|
75
|
+
desktop remove Remove only the managed desktop entry and protocol handler.
|
|
76
|
+
`;
|
|
64
77
|
const SHUTDOWN_HELP_TEXT = `Usage:
|
|
65
78
|
tutti shutdown [--yes]
|
|
66
79
|
|
|
@@ -125,6 +138,9 @@ function helpForTopic(topic) {
|
|
|
125
138
|
if (topic === "service") {
|
|
126
139
|
return SERVICE_HELP_TEXT;
|
|
127
140
|
}
|
|
141
|
+
if (topic === "desktop") {
|
|
142
|
+
return DESKTOP_HELP_TEXT;
|
|
143
|
+
}
|
|
128
144
|
if (topic === "shutdown") {
|
|
129
145
|
return SHUTDOWN_HELP_TEXT;
|
|
130
146
|
}
|
|
@@ -184,6 +200,15 @@ async function runProviderSetupCommand(target) {
|
|
|
184
200
|
].join("\n"));
|
|
185
201
|
process.stdout.write("\n");
|
|
186
202
|
}
|
|
203
|
+
function ensureDesktopIntegrationForConsole() {
|
|
204
|
+
const desktop = ensureDesktopIntegrationAutomatically();
|
|
205
|
+
if (desktop.status === "unavailable" &&
|
|
206
|
+
(desktop.reason_code === "record_invalid" ||
|
|
207
|
+
desktop.reason_code === "entry_conflict" ||
|
|
208
|
+
desktop.reason_code === "operation_failed")) {
|
|
209
|
+
process.stderr.write("Tutti could not update its desktop entry. Run `tutti desktop` for details.\n");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
187
212
|
async function runLocalConsoleCommand() {
|
|
188
213
|
const console = await ensureLocalConsole();
|
|
189
214
|
if (openLocalConsoleBrowser(console.url)) {
|
|
@@ -192,6 +217,47 @@ async function runLocalConsoleCommand() {
|
|
|
192
217
|
}
|
|
193
218
|
process.stdout.write(["Open Tutti in a browser:", console.url, ""].join("\n"));
|
|
194
219
|
}
|
|
220
|
+
function desktopResultMessage(result) {
|
|
221
|
+
if (result.status === "installed") {
|
|
222
|
+
return [
|
|
223
|
+
"Tutti desktop: installed",
|
|
224
|
+
`Entry: ${result.entry_path}`,
|
|
225
|
+
"Protocol: tutti://projects",
|
|
226
|
+
].join("\n");
|
|
227
|
+
}
|
|
228
|
+
if (result.status === "removed") {
|
|
229
|
+
return ["Tutti desktop: removed", `Entry: ${result.entry_path}`].join("\n");
|
|
230
|
+
}
|
|
231
|
+
if (result.status === "skipped") {
|
|
232
|
+
return "Tutti desktop: removed\nRun `tutti desktop` to install it again.";
|
|
233
|
+
}
|
|
234
|
+
throw new LaunchError("desktop_integration_unavailable", result.reason, result.reason_code === "package_not_global"
|
|
235
|
+
? "Install Tutti globally with `npm install -g @xfey/tutti`, then run `tutti desktop`."
|
|
236
|
+
: "Resolve the reported desktop entry conflict or platform requirement, then run `tutti desktop` again.");
|
|
237
|
+
}
|
|
238
|
+
function runDesktopCommand(action) {
|
|
239
|
+
return desktopResultMessage(runDesktopIntegrationCommand(action));
|
|
240
|
+
}
|
|
241
|
+
async function runDesktopOpenCommand(value) {
|
|
242
|
+
const url = value ?? TUTTI_DESKTOP_PROJECTS_URL;
|
|
243
|
+
try {
|
|
244
|
+
parseTuttiDesktopUrl(url);
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (error instanceof TuttiDesktopProtocolError) {
|
|
248
|
+
throw new LaunchError("desktop_protocol_invalid", error.message, "Open Tutti from its desktop icon or use the fixed tutti://projects URL.");
|
|
249
|
+
}
|
|
250
|
+
throw error;
|
|
251
|
+
}
|
|
252
|
+
const cliEntrypoint = process.argv[1];
|
|
253
|
+
const console = await ensureLocalConsole({
|
|
254
|
+
source: "desktop",
|
|
255
|
+
...(cliEntrypoint === undefined ? {} : { cwd: dirname(resolve(cliEntrypoint)) }),
|
|
256
|
+
});
|
|
257
|
+
if (!openLocalConsoleBrowser(console.url)) {
|
|
258
|
+
throw new LaunchError("desktop_open_failed", "The Local Console started, but no desktop browser opener is available.", "Run `tutti` from a terminal to print a one-time browser URL.");
|
|
259
|
+
}
|
|
260
|
+
}
|
|
195
261
|
async function runServiceCommand(action) {
|
|
196
262
|
if (action === "status") {
|
|
197
263
|
const status = await readLocalConsoleServiceStatus();
|
|
@@ -272,6 +338,9 @@ async function runShutdownCommand(yes) {
|
|
|
272
338
|
}
|
|
273
339
|
try {
|
|
274
340
|
const command = parseCliArgs(process.argv.slice(2));
|
|
341
|
+
if (shouldEnsureDesktopIntegrationAutomatically(command)) {
|
|
342
|
+
ensureDesktopIntegrationForConsole();
|
|
343
|
+
}
|
|
275
344
|
switch (command.kind) {
|
|
276
345
|
case "console":
|
|
277
346
|
await runLocalConsoleCommand();
|
|
@@ -282,6 +351,9 @@ try {
|
|
|
282
351
|
case "version":
|
|
283
352
|
process.stdout.write(`${readCliVersion()}\n`);
|
|
284
353
|
break;
|
|
354
|
+
case "desktop":
|
|
355
|
+
process.stdout.write(`${runDesktopCommand(command.action)}\n`);
|
|
356
|
+
break;
|
|
285
357
|
case "service":
|
|
286
358
|
process.stdout.write(`${await runServiceCommand(command.action)}\n`);
|
|
287
359
|
break;
|
|
@@ -303,6 +375,9 @@ try {
|
|
|
303
375
|
case "internal-console-run":
|
|
304
376
|
await runLocalConsoleProcess();
|
|
305
377
|
break;
|
|
378
|
+
case "internal-desktop-open":
|
|
379
|
+
await runDesktopOpenCommand(command.url);
|
|
380
|
+
break;
|
|
306
381
|
case "ps":
|
|
307
382
|
if (command.manage) {
|
|
308
383
|
await runPsManageCommand({ all: command.all });
|
|
@@ -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" | "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";
|
|
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" | "desktop_integration_unavailable" | "desktop_protocol_invalid" | "desktop_open_failed";
|
|
2
2
|
export declare class LaunchError extends Error {
|
|
3
3
|
readonly code: LaunchErrorCode;
|
|
4
4
|
readonly next: string;
|
|
@@ -2,6 +2,7 @@ import { basename, join } from "node:path";
|
|
|
2
2
|
import { serializeRuntimeLogLine } from "@tutti/shared/utils";
|
|
3
3
|
import { completeRelayUpload, resolveRelayUpload } from "@tutti/relay-client";
|
|
4
4
|
import { ApprovalRequestManager } from "../../approvals/index.js";
|
|
5
|
+
import { ArtifactPreviewManager } from "../../artifacts/index.js";
|
|
5
6
|
import { ReadOnlyChatAssistant } from "../../chat-assistant/index.js";
|
|
6
7
|
import { ensureInitialProjectWelcomeMessage } from "../../collaboration-state/index.js";
|
|
7
8
|
import { Phase5ControlPlane } from "../../control-plane/index.js";
|
|
@@ -208,12 +209,30 @@ export async function startForegroundHostServer(options) {
|
|
|
208
209
|
events: workspaceEvents,
|
|
209
210
|
now,
|
|
210
211
|
});
|
|
212
|
+
const artifactPreviewManager = new ArtifactPreviewManager({
|
|
213
|
+
onStatusChange: () => {
|
|
214
|
+
workspaceEvents.publish({
|
|
215
|
+
event_type: "query.invalidate",
|
|
216
|
+
payload: {
|
|
217
|
+
reason: "bulk_state_change",
|
|
218
|
+
message: "Artifacts changed.",
|
|
219
|
+
},
|
|
220
|
+
invalidates: [{ kind: "artifacts" }],
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
});
|
|
211
224
|
const controlPlane = new Phase5ControlPlane({
|
|
212
225
|
store,
|
|
213
226
|
events: workspaceEvents,
|
|
214
227
|
now,
|
|
215
228
|
logger: controlPlaneLogger,
|
|
216
229
|
projectContext,
|
|
230
|
+
runLifecycle: {
|
|
231
|
+
started: () => artifactPreviewManager.beginWorkspaceUpdate(),
|
|
232
|
+
settled: () => artifactPreviewManager.settleWorkspaceUpdate({
|
|
233
|
+
workspaceRoot: options.preparation.workspace_root,
|
|
234
|
+
}),
|
|
235
|
+
},
|
|
217
236
|
readActiveApproval: () => approvalManager.getActiveApproval(),
|
|
218
237
|
readMainlineStatus: () => ({
|
|
219
238
|
mainline_clean: readHostRepoProjection(options.preparation.workspace_root).mainline_clean,
|
|
@@ -362,6 +381,7 @@ export async function startForegroundHostServer(options) {
|
|
|
362
381
|
: { refreshJoinUrl: options.refreshRelayJoinUrl }),
|
|
363
382
|
},
|
|
364
383
|
trustedMetadataStore,
|
|
384
|
+
artifactPreviewManager,
|
|
365
385
|
store,
|
|
366
386
|
events: workspaceEvents,
|
|
367
387
|
controlPlane,
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export function readCliPackageIdentity() {
|
|
3
5
|
const packageUrl = new URL("../../../package.json", import.meta.url);
|
|
4
6
|
const parsed = JSON.parse(readFileSync(packageUrl, "utf8"));
|
|
5
|
-
return
|
|
7
|
+
return {
|
|
8
|
+
name: typeof parsed.name === "string" ? parsed.name : "unknown",
|
|
9
|
+
version: typeof parsed.version === "string" ? parsed.version : "0.0.0",
|
|
10
|
+
root: resolve(dirname(fileURLToPath(packageUrl))),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export function readCliVersion() {
|
|
14
|
+
return readCliPackageIdentity().version;
|
|
6
15
|
}
|
|
7
16
|
//# sourceMappingURL=version.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type DesktopCommandRunner, type DesktopInstallationIdentity, type DesktopPlatformAdapter } from "./platform.js";
|
|
2
|
+
export declare function findDesktopExecutable(name: string, env: NodeJS.ProcessEnv): string | null;
|
|
3
|
+
export declare function quoteDesktopExecArgument(value: string): string;
|
|
4
|
+
export declare function renderLinuxDesktopEntry(identity: DesktopInstallationIdentity, iconPath: string): string;
|
|
5
|
+
export declare function createLinuxDesktopAdapter(options: {
|
|
6
|
+
homeDir: string;
|
|
7
|
+
assetRoot: string;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
runner?: DesktopCommandRunner;
|
|
10
|
+
}): DesktopPlatformAdapter;
|
|
11
|
+
export declare function linuxDesktopRequirementsAvailable(assetRoot: string, env: NodeJS.ProcessEnv): boolean;
|
|
12
|
+
//# sourceMappingURL=linux.d.ts.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { requireDesktopCommand, runDesktopCommand, } from "./platform.js";
|
|
4
|
+
const DESKTOP_FILE_NAME = "now.tutti.Tutti.desktop";
|
|
5
|
+
const DESKTOP_MIME = "x-scheme-handler/tutti";
|
|
6
|
+
export function findDesktopExecutable(name, env) {
|
|
7
|
+
for (const directory of (env.PATH ?? "").split(delimiter)) {
|
|
8
|
+
if (directory.trim() === "") {
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
const candidate = join(directory, name);
|
|
12
|
+
if (existsSync(candidate)) {
|
|
13
|
+
return candidate;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
export function quoteDesktopExecArgument(value) {
|
|
19
|
+
if (value.includes("\n") || value.includes("\r")) {
|
|
20
|
+
throw new Error("Desktop executable paths cannot contain line breaks.");
|
|
21
|
+
}
|
|
22
|
+
const escaped = value.replaceAll("%", "%%").replace(/([\\"`$])/gu, "\\$1");
|
|
23
|
+
return `"${escaped}"`;
|
|
24
|
+
}
|
|
25
|
+
export function renderLinuxDesktopEntry(identity, iconPath) {
|
|
26
|
+
return [
|
|
27
|
+
"[Desktop Entry]",
|
|
28
|
+
"Type=Application",
|
|
29
|
+
"Version=1.0",
|
|
30
|
+
"Name=Tutti",
|
|
31
|
+
"Comment=Open the Tutti local project manager",
|
|
32
|
+
`Exec=${quoteDesktopExecArgument(identity.nodeExecutable)} ${quoteDesktopExecArgument(identity.cliEntrypoint)} internal desktop-open %u`,
|
|
33
|
+
`TryExec=${identity.nodeExecutable}`,
|
|
34
|
+
`Icon=${iconPath}`,
|
|
35
|
+
"Terminal=false",
|
|
36
|
+
"NoDisplay=false",
|
|
37
|
+
"StartupNotify=false",
|
|
38
|
+
"Categories=Development;",
|
|
39
|
+
`MimeType=${DESKTOP_MIME};`,
|
|
40
|
+
"X-Tutti-Managed=true",
|
|
41
|
+
"X-Tutti-Schema-Version=1",
|
|
42
|
+
"",
|
|
43
|
+
].join("\n");
|
|
44
|
+
}
|
|
45
|
+
function writeAtomic(path, contents, mode) {
|
|
46
|
+
const directory = dirname(path);
|
|
47
|
+
mkdirSync(directory, { recursive: true });
|
|
48
|
+
const temporaryPath = join(directory, `.tutti-${process.pid}-${Date.now()}.tmp`);
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(temporaryPath, contents, { mode });
|
|
51
|
+
chmodSync(temporaryPath, mode);
|
|
52
|
+
renameSync(temporaryPath, path);
|
|
53
|
+
chmodSync(path, mode);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
rmSync(temporaryPath, { force: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function removeOwnedAssociation(path) {
|
|
60
|
+
if (!existsSync(path)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const original = readFileSync(path, "utf8");
|
|
64
|
+
let section = "";
|
|
65
|
+
const lines = original.split(/\r?\n/u);
|
|
66
|
+
const output = [];
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const sectionMatch = /^\s*\[([^\]]+)\]\s*$/u.exec(line);
|
|
69
|
+
if (sectionMatch !== null) {
|
|
70
|
+
section = sectionMatch[1] ?? "";
|
|
71
|
+
output.push(line);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if ((section === "Default Applications" || section === "Added Associations") &&
|
|
75
|
+
line.startsWith(`${DESKTOP_MIME}=`)) {
|
|
76
|
+
const handlers = line
|
|
77
|
+
.slice(DESKTOP_MIME.length + 1)
|
|
78
|
+
.split(";")
|
|
79
|
+
.filter((handler) => handler !== "" && handler !== DESKTOP_FILE_NAME);
|
|
80
|
+
if (handlers.length > 0) {
|
|
81
|
+
output.push(`${DESKTOP_MIME}=${handlers.join(";")};`);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
output.push(line);
|
|
86
|
+
}
|
|
87
|
+
const updated = output.join("\n");
|
|
88
|
+
if (updated !== original) {
|
|
89
|
+
writeAtomic(path, updated, 0o600);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function createLinuxDesktopAdapter(options) {
|
|
93
|
+
const env = options.env ?? process.env;
|
|
94
|
+
const runner = options.runner ?? runDesktopCommand;
|
|
95
|
+
const dataHome = env.XDG_DATA_HOME !== undefined && isAbsolute(env.XDG_DATA_HOME)
|
|
96
|
+
? env.XDG_DATA_HOME
|
|
97
|
+
: join(options.homeDir, ".local", "share");
|
|
98
|
+
const configHome = env.XDG_CONFIG_HOME !== undefined && isAbsolute(env.XDG_CONFIG_HOME)
|
|
99
|
+
? env.XDG_CONFIG_HOME
|
|
100
|
+
: join(options.homeDir, ".config");
|
|
101
|
+
const applicationsDir = join(dataHome, "applications");
|
|
102
|
+
const entryPath = join(applicationsDir, DESKTOP_FILE_NAME);
|
|
103
|
+
const iconPath = join(dataHome, "icons", "hicolor", "512x512", "apps", "now.tutti.Tutti.png");
|
|
104
|
+
const assetIconPath = join(options.assetRoot, "Tutti-square.png");
|
|
105
|
+
const xdgMime = findDesktopExecutable("xdg-mime", env);
|
|
106
|
+
const updateDesktopDatabase = findDesktopExecutable("update-desktop-database", env);
|
|
107
|
+
const inspect = () => {
|
|
108
|
+
if (!existsSync(entryPath)) {
|
|
109
|
+
return "missing";
|
|
110
|
+
}
|
|
111
|
+
const contents = readFileSync(entryPath, "utf8");
|
|
112
|
+
return contents.includes("X-Tutti-Managed=true") &&
|
|
113
|
+
contents.includes("X-Tutti-Schema-Version=1")
|
|
114
|
+
? "owned"
|
|
115
|
+
: "conflict";
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
platform: "linux",
|
|
119
|
+
entryPath,
|
|
120
|
+
inspect,
|
|
121
|
+
install(identity) {
|
|
122
|
+
if (inspect() === "conflict") {
|
|
123
|
+
throw new Error("A desktop entry not managed by Tutti already occupies the target path.");
|
|
124
|
+
}
|
|
125
|
+
if (xdgMime === null || !existsSync(assetIconPath)) {
|
|
126
|
+
throw new Error("The Linux desktop registration tools or packaged icon are unavailable.");
|
|
127
|
+
}
|
|
128
|
+
const previousEntry = existsSync(entryPath) ? readFileSync(entryPath) : null;
|
|
129
|
+
const previousIcon = existsSync(iconPath) ? readFileSync(iconPath) : null;
|
|
130
|
+
try {
|
|
131
|
+
writeAtomic(entryPath, renderLinuxDesktopEntry(identity, iconPath), 0o644);
|
|
132
|
+
mkdirSync(dirname(iconPath), { recursive: true });
|
|
133
|
+
copyFileSync(assetIconPath, iconPath);
|
|
134
|
+
chmodSync(iconPath, 0o644);
|
|
135
|
+
requireDesktopCommand(runner, xdgMime, ["default", DESKTOP_FILE_NAME, DESKTOP_MIME], "Linux URL scheme registration");
|
|
136
|
+
if (updateDesktopDatabase !== null) {
|
|
137
|
+
runner(updateDesktopDatabase, [applicationsDir]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (previousEntry === null) {
|
|
142
|
+
rmSync(entryPath, { force: true });
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
writeAtomic(entryPath, previousEntry, 0o644);
|
|
146
|
+
}
|
|
147
|
+
if (previousIcon === null) {
|
|
148
|
+
rmSync(iconPath, { force: true });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
writeAtomic(iconPath, previousIcon, 0o644);
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
remove() {
|
|
157
|
+
const inspection = inspect();
|
|
158
|
+
if (inspection === "conflict") {
|
|
159
|
+
throw new Error("The existing Linux desktop entry is not managed by Tutti.");
|
|
160
|
+
}
|
|
161
|
+
if (inspection === "owned") {
|
|
162
|
+
rmSync(entryPath, { force: true });
|
|
163
|
+
rmSync(iconPath, { force: true });
|
|
164
|
+
}
|
|
165
|
+
else if (existsSync(iconPath) &&
|
|
166
|
+
existsSync(assetIconPath) &&
|
|
167
|
+
readFileSync(iconPath).equals(readFileSync(assetIconPath))) {
|
|
168
|
+
rmSync(iconPath, { force: true });
|
|
169
|
+
}
|
|
170
|
+
for (const mimeappsPath of new Set([
|
|
171
|
+
join(configHome, "mimeapps.list"),
|
|
172
|
+
join(applicationsDir, "mimeapps.list"),
|
|
173
|
+
])) {
|
|
174
|
+
removeOwnedAssociation(mimeappsPath);
|
|
175
|
+
}
|
|
176
|
+
if (updateDesktopDatabase !== null) {
|
|
177
|
+
runner(updateDesktopDatabase, [applicationsDir]);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export function linuxDesktopRequirementsAvailable(assetRoot, env) {
|
|
183
|
+
return (findDesktopExecutable("xdg-mime", env) !== null &&
|
|
184
|
+
existsSync(join(assetRoot, "Tutti-square.png")));
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=linux.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type DesktopCommandRunner, type DesktopInstallationIdentity, type DesktopPlatformAdapter } from "./platform.js";
|
|
2
|
+
export declare function renderMacosHandlerTemplate(template: string, identity: DesktopInstallationIdentity): string;
|
|
3
|
+
export declare function createMacosDesktopAdapter(options: {
|
|
4
|
+
homeDir: string;
|
|
5
|
+
assetRoot: string;
|
|
6
|
+
runner?: DesktopCommandRunner;
|
|
7
|
+
}): DesktopPlatformAdapter;
|
|
8
|
+
export declare function macosDesktopRequirementsAvailable(assetRoot: string): boolean;
|
|
9
|
+
//# sourceMappingURL=macos.d.ts.map
|