@xfey/tutti 0.1.54 → 0.1.56
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/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/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/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/package.json +2 -1
- package/web/assets/{homepage-motion-scene-C96Na8TV.js → homepage-motion-scene-BB1uSUBr.js} +1 -1
- package/web/assets/{index-nWy1QP97.css → index-8lX8GhfZ.css} +1 -1
- package/web/assets/{index-CM41hrQd.js → index-DxkJrQ7e.js} +13 -14
- package/web/index.html +2 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
- `tutti launch` 后的项目绑定、恢复和接管入口
|
|
10
10
|
- 裸 `tutti` 经 lifecycle lock 启动 / 复用的版本感知 loopback Local Console、独立 invocation context,以及既有项目 Host 编排
|
|
11
|
+
- 首次 eligible 裸 `tutti`、显式 `tutti desktop` 与严格 `tutti://projects` bridge 共用的用户级 desktop integration manager
|
|
11
12
|
- Fastify API、SSE、packaged static Web 产物服务和 server 生命周期
|
|
12
13
|
- SQLite store、migrations、repository 与协作真相读写
|
|
13
14
|
- `control-plane`、scheduler、`procedure-engine`、`run-engine`、`workspace-ops` 与 OpenAI provider 接入
|
|
@@ -18,18 +19,20 @@
|
|
|
18
19
|
- `src/`:server 代码根目录。
|
|
19
20
|
- `prompts/`:大模型 prompt、指令模板和 Codex 执行模板的统一保存目录。
|
|
20
21
|
- `migrations/`:本地 `tutti.sqlite` 的 SQL migrations。
|
|
22
|
+
- `desktop-assets/`:随 npm package 发布的 macOS AppleScriptObjC handler、白底 squircle desktop PNG / ICNS 与资产说明。
|
|
21
23
|
- `cli-package.mjs`:生成临时 staging `@xfey/tutti` package artifact 的脚本。
|
|
22
24
|
- `prepare-package-assets.mjs`:把 Web build output 和内部 runtime package 复制到 staging package 所需位置。
|
|
23
25
|
- `pack-dry-run.mjs`:构建 staging package 并执行 `npm pack --dry-run --json` 文件列表校验。
|
|
24
26
|
- `smoke-packed-cli.mjs`:安装并验证 packed CLI tarball 的本地 smoke,包括 CLI help / version、runtime deps 和 QR dependency。
|
|
27
|
+
- `smoke-desktop-protocol.mjs`:在真实 `~/Applications` 下使用唯一临时 bundle / scheme 验证 macOS 图标点击与 LaunchServices URL event,并在结束时注销、清理。
|
|
25
28
|
|
|
26
29
|
## 当前状态
|
|
27
30
|
|
|
28
|
-
- 已具备 Fastify host shell、后台 host manager CLI
|
|
31
|
+
- 已具备 Fastify host shell、后台 host manager CLI 套件、用户级 desktop integration / strict `tutti://projects` bridge、带 `service status / stop / restart`、卸载前 `shutdown`、并发启动锁、版本替换、strict terminal / desktop browser context、idle / package update cleanup 与私有日志的机器级 Local Console、Dev Runner、Host Project API、SSE、SQLite store、collaboration-state、workspace read-state、project timeline observability helper、Control Plane、Procedure / Run、workspace-ops、checks、Relay staged upload finalize、user skills 文件服务、artifacts 可视化产物查看层和 OpenAI / Codex provider 接入。
|
|
29
32
|
- 新 Host project 的空群聊会由 collaboration-state 幂等写入一条 Tutti 欢迎消息;它不经过 browser message command,不触发 `@tutti`、Chat read-state、Scratchpad 或 Timeline / execution 流程。
|
|
30
33
|
- Host server 与 Local Console 支持在显式 `webStatic.root` 或 packaged `web/` 存在时服务 Web 静态产物;`/api/*`、SSE、health、host-local control 和 `/local-console/*` API 不被 SPA fallback 吞掉。
|
|
31
34
|
- Root `pack:dry-run` 会先清理 build output、重新构建,再复制 `apps/web/dist` 到 package-local `web/`,生成临时 staging `@xfey/tutti` artifact,并校验 npm pack 文件列表包含 CLI、migrations、prompts、Web index、bundled `@tutti/shared` / `@tutti/relay-client` 以及它们的外部 runtime dependencies,同时排除源码、source map、日志、SQLite、`.env`、`.tutti-dev` 与测试产物。
|
|
32
|
-
- Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / `tutti service --help` / `tutti service status` / `tutti shutdown --help` / `tutti launch --help` / `tutti archive --help` / `tutti --version`、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
|
|
35
|
+
- Root `smoke:packed-cli` 同样从干净 build output 生成 staging `@xfey/tutti` tarball、在临时目录安装,并验证 SQLite native binding、`tutti --help` / desktop help / `tutti service --help` / `tutti service status` / `tutti shutdown --help` / `tutti launch --help` / `tutti archive --help` / `tutti --version`、desktop assets、migrations、prompts、Web static index、bundled internal dependencies、bundled external runtime dependencies、CLI QR dependency、仓库锁定的 `@openai/codex` binary 可执行性和临时全局安装路径;该 smoke 不需要 Tutti source repo 存在。
|
|
33
36
|
- 对外发布必须把同一 staging package 通过 `npm pack` 生成的已核验 `.tgz` 交给 `npm publish`,不得直接发布 staging 目录;npm 的目录发布路径可能重新收集文件并遗漏 staging 内的 bundled dependencies。发布后必须从 registry 临时执行 CLI version / help smoke。
|
|
34
37
|
- `prompts/` 是所有模型-facing prompt、指令模板和 Codex 执行模板的统一落点;长 prompt 不内联在 TypeScript 中。
|
|
35
38
|
- Host Project API 只信任 Relay tunnel metadata 注入的 `relay_session_context`;direct loopback 缺少可信 context 时不作为浏览器工作区入口。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Desktop assets
|
|
2
|
+
|
|
3
|
+
This directory contains package-owned assets used by the user-level Tutti desktop integration.
|
|
4
|
+
|
|
5
|
+
- `macos-handler.applescript` is compiled into `~/Applications/Tutti.app` by the packaged CLI. Its
|
|
6
|
+
AppleScriptObjC handler launches the hidden desktop bridge through `NSTask`, with a fixed
|
|
7
|
+
`tutti://projects` desktop intent and no shell invocation.
|
|
8
|
+
- `Tutti-square.png` is the 1024px desktop icon: a white macOS-style continuous-corner squircle
|
|
9
|
+
with the canonical Tutti wordmark enlarged inside a safe inset. Linux uses the same visual.
|
|
10
|
+
- `Tutti.icns` is the multiresolution macOS app icon generated from that desktop PNG.
|
|
11
|
+
|
|
12
|
+
The assets are shipped inside `@xfey/tutti`; installation never downloads runtime files. They do
|
|
13
|
+
not contain project paths, credentials, tokens, Relay sessions, or Local Console endpoints.
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
use framework "Foundation"
|
|
2
|
+
use scripting additions
|
|
3
|
+
|
|
4
|
+
property nodeExecutable : __TUTTI_NODE_EXECUTABLE_STRING__
|
|
5
|
+
property cliEntrypoint : __TUTTI_CLI_ENTRYPOINT_STRING__
|
|
6
|
+
property projectsUrl : "tutti://projects"
|
|
7
|
+
|
|
8
|
+
on launchTutti(targetUrl)
|
|
9
|
+
set task to current application's NSTask's alloc()'s init()
|
|
10
|
+
task's setLaunchPath:nodeExecutable
|
|
11
|
+
task's setArguments:{cliEntrypoint, "internal", "desktop-open", targetUrl}
|
|
12
|
+
task's setStandardInput:(current application's NSFileHandle's fileHandleWithNullDevice())
|
|
13
|
+
task's setStandardOutput:(current application's NSFileHandle's fileHandleWithNullDevice())
|
|
14
|
+
task's setStandardError:(current application's NSFileHandle's fileHandleWithNullDevice())
|
|
15
|
+
task's |launch|()
|
|
16
|
+
end launchTutti
|
|
17
|
+
|
|
18
|
+
on run
|
|
19
|
+
launchTutti(projectsUrl)
|
|
20
|
+
end run
|
|
21
|
+
|
|
22
|
+
on open location targetUrl
|
|
23
|
+
launchTutti(targetUrl)
|
|
24
|
+
end open location
|
|
@@ -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;
|
|
@@ -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
|