@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.
Files changed (63) hide show
  1. package/README.md +5 -2
  2. package/desktop-assets/README.md +13 -0
  3. package/desktop-assets/Tutti-square.png +0 -0
  4. package/desktop-assets/Tutti.icns +0 -0
  5. package/desktop-assets/macos-handler.applescript +24 -0
  6. package/dist/artifacts/index.d.ts +3 -3
  7. package/dist/artifacts/index.js +3 -3
  8. package/dist/artifacts/live-manifest.d.ts +23 -0
  9. package/dist/artifacts/live-manifest.js +115 -0
  10. package/dist/artifacts/preview-runtime.d.ts +34 -36
  11. package/dist/artifacts/preview-runtime.js +215 -273
  12. package/dist/artifacts/static-preview.d.ts +2 -1
  13. package/dist/artifacts/static-preview.js +19 -21
  14. package/dist/control-plane/index.d.ts +1 -0
  15. package/dist/control-plane/index.js +13 -2
  16. package/dist/control-plane/types.d.ts +4 -0
  17. package/dist/server-shell/cli/args.d.ts +7 -0
  18. package/dist/server-shell/cli/args.js +32 -0
  19. package/dist/server-shell/cli/cli.js +76 -1
  20. package/dist/server-shell/cli/errors.d.ts +1 -1
  21. package/dist/server-shell/cli/host-server-runtime.js +20 -0
  22. package/dist/server-shell/cli/version.d.ts +6 -0
  23. package/dist/server-shell/cli/version.js +11 -2
  24. package/dist/server-shell/desktop-integration/index.d.ts +5 -0
  25. package/dist/server-shell/desktop-integration/index.js +5 -0
  26. package/dist/server-shell/desktop-integration/linux.d.ts +12 -0
  27. package/dist/server-shell/desktop-integration/linux.js +186 -0
  28. package/dist/server-shell/desktop-integration/macos.d.ts +9 -0
  29. package/dist/server-shell/desktop-integration/macos.js +138 -0
  30. package/dist/server-shell/desktop-integration/manager.d.ts +40 -0
  31. package/dist/server-shell/desktop-integration/manager.js +223 -0
  32. package/dist/server-shell/desktop-integration/platform.d.ts +23 -0
  33. package/dist/server-shell/desktop-integration/platform.js +21 -0
  34. package/dist/server-shell/desktop-integration/protocol.d.ts +9 -0
  35. package/dist/server-shell/desktop-integration/protocol.js +28 -0
  36. package/dist/server-shell/desktop-integration/record.d.ts +17 -0
  37. package/dist/server-shell/desktop-integration/record.js +74 -0
  38. package/dist/server-shell/http/routes/project-api/artifacts-routes.js +43 -110
  39. package/dist/server-shell/http/routes/project-api/openapi-artifacts-routes.d.ts +5 -8
  40. package/dist/server-shell/http/routes/project-api/openapi-artifacts-routes.js +5 -33
  41. package/dist/server-shell/http/routes/project-api/openapi.d.ts +17 -8
  42. package/dist/server-shell/http/routes/project-api/types.d.ts +3 -2
  43. package/dist/server-shell/local-console/invocation-context.d.ts +7 -0
  44. package/dist/server-shell/local-console/invocation-context.js +8 -9
  45. package/dist/server-shell/local-console/managed-console.d.ts +1 -0
  46. package/dist/server-shell/local-console/managed-console.js +10 -5
  47. package/dist/server-shell/local-console/project-service.js +1 -1
  48. package/dist/server-shell/local-console/server.js +23 -6
  49. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.d.ts +7 -88
  50. package/node_modules/@tutti/shared/dist/schemas/api/artifacts.js +3 -48
  51. package/node_modules/@tutti/shared/dist/schemas/api/index.d.ts +1 -1
  52. package/node_modules/@tutti/shared/dist/schemas/api/index.js +1 -1
  53. package/node_modules/@tutti/shared/dist/schemas/api/types.d.ts +1 -10
  54. package/package.json +2 -1
  55. package/web/assets/{homepage-motion-scene-F4ibPvI9.js → homepage-motion-scene-D0-wLq3D.js} +1 -1
  56. package/web/assets/{index-B1mBtL9x.js → index-BAsLSZ5U.js} +14 -14
  57. package/web/assets/index-DMaiDZ9k.css +1 -0
  58. package/web/assets/tutti_avatar-_0Yc-cMJ.png +0 -0
  59. package/web/index.html +2 -2
  60. package/dist/artifacts/manifest.d.ts +0 -41
  61. package/dist/artifacts/manifest.js +0 -169
  62. package/web/assets/index-OFlcQ3eS.css +0 -1
  63. package/web/assets/tutti_avatar-BBhaZGi3.png +0 -0
@@ -1,9 +1,9 @@
1
- import { posix } from "node:path";
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 { readArtifactDeclaration } from "./manifest.js";
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(readArtifactDeclaration({
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
- const entry = lsPath(options.workspaceRoot, repoPath);
154
- if (entry === null) {
155
- throw new WorkspaceOpsError("not_found", "Artifact preview file was not found");
156
- }
157
- if (entryKind(entry) !== "file") {
158
- throw new WorkspaceOpsError("validation_failed", "Artifact preview path is not a file");
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
- if (entry.size === undefined || !Number.isSafeInteger(entry.size)) {
161
- throw new WorkspaceOpsError("git_failed", "Artifact preview file size is unavailable");
162
+ catch {
163
+ throw new WorkspaceOpsError("not_found", "Artifact preview file was not found");
162
164
  }
163
- if (entry.size > VIEWER_HARD_READ_LIMIT_BYTES) {
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: readBlob(options.workspaceRoot, fileEntry.oid, fileEntry.size),
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
- return startControlPlaneRunForTask({
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) => this.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,2 +1,8 @@
1
+ export type CliPackageIdentity = {
2
+ name: string;
3
+ version: string;
4
+ root: string;
5
+ };
6
+ export declare function readCliPackageIdentity(): CliPackageIdentity;
1
7
  export declare function readCliVersion(): string;
2
8
  //# sourceMappingURL=version.d.ts.map
@@ -1,7 +1,16 @@
1
1
  import { readFileSync } from "node:fs";
2
- export function readCliVersion() {
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 typeof parsed.version === "string" ? parsed.version : "0.0.0";
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,5 @@
1
+ export * from "./manager.js";
2
+ export * from "./platform.js";
3
+ export * from "./protocol.js";
4
+ export * from "./record.js";
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export * from "./manager.js";
2
+ export * from "./platform.js";
3
+ export * from "./protocol.js";
4
+ export * from "./record.js";
5
+ //# sourceMappingURL=index.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