overmux 0.0.1 → 0.0.2

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 (193) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +7 -0
  3. package/dist/auth/.vite/manifest.json +11 -0
  4. package/dist/auth/assets/index-Bx8JhwVi.css +1 -0
  5. package/dist/auth/assets/index-CHIE7qYj.js +72 -0
  6. package/dist/auth/index.html +14 -0
  7. package/dist/bin.js +4504 -7870
  8. package/dist/bin.js.map +1 -0
  9. package/dist/docs/000-index.md +21 -0
  10. package/dist/docs/100-introduction/100-what-is-overmux.md +7 -0
  11. package/dist/docs/100-introduction/200-how-overmux-works.md +7 -0
  12. package/dist/docs/100-introduction/300-why-overmux.md +22 -0
  13. package/dist/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  14. package/dist/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  15. package/dist/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  16. package/dist/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  17. package/dist/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  18. package/dist/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  19. package/dist/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  20. package/dist/docs/300-fundamentals/100-project-structure.md +3 -0
  21. package/dist/docs/300-fundamentals/200-configuration.md +3 -0
  22. package/dist/docs/300-fundamentals/300-theming.md +3 -0
  23. package/dist/docs/300-fundamentals/400-server.md +3 -0
  24. package/dist/docs/300-fundamentals/500-client.md +3 -0
  25. package/dist/docs/300-fundamentals/600-operations.md +3 -0
  26. package/dist/docs/300-fundamentals/700-resources.md +3 -0
  27. package/dist/docs/300-fundamentals/800-streams.md +3 -0
  28. package/dist/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  29. package/dist/docs/400-reference/100-configuration.md +3 -0
  30. package/dist/docs/400-reference/200-server-api.md +3 -0
  31. package/dist/docs/400-reference/300-client-api.md +3 -0
  32. package/dist/docs/400-reference/400-cli/100-serve.md +33 -0
  33. package/dist/docs/400-reference/400-cli/200-auth.md +52 -0
  34. package/dist/docs/400-reference/400-cli/300-call.md +28 -0
  35. package/dist/docs/400-reference/400-cli/400-check.md +20 -0
  36. package/dist/docs/400-reference/400-cli/500-ai-context.md +102 -0
  37. package/dist/docs/400-reference/400-cli/600-docs.md +23 -0
  38. package/dist/docs/400-reference/400-cli/700-desktop.md +35 -0
  39. package/dist/docs/500-hosted-pages.md +31 -0
  40. package/dist/exports/client.d.ts +237 -0
  41. package/dist/exports/client.d.ts.map +1 -0
  42. package/dist/exports/client.js +1643 -0
  43. package/dist/exports/client.js.map +1 -0
  44. package/dist/exports/index-D3_Dpy_T.d.ts +155 -0
  45. package/dist/exports/index-D3_Dpy_T.d.ts.map +1 -0
  46. package/dist/exports/index.d.ts +2 -0
  47. package/dist/exports/index.js +49 -0
  48. package/dist/exports/index.js.map +1 -0
  49. package/dist/exports/notifications-kDp16bU_.js +56 -0
  50. package/dist/exports/notifications-kDp16bU_.js.map +1 -0
  51. package/dist/exports/server.d.ts +63 -0
  52. package/dist/exports/server.d.ts.map +1 -0
  53. package/dist/exports/server.js +1120 -0
  54. package/dist/exports/server.js.map +1 -0
  55. package/dist/exports/style.css +368 -0
  56. package/dist/internal/server/coordinator/server-child.d.ts +1 -0
  57. package/dist/internal/server/coordinator/server-child.js +3299 -0
  58. package/dist/internal/server/coordinator/server-child.js.map +1 -0
  59. package/docs/000-index.md +21 -0
  60. package/docs/100-introduction/100-what-is-overmux.md +7 -0
  61. package/docs/100-introduction/200-how-overmux-works.md +7 -0
  62. package/docs/100-introduction/300-why-overmux.md +22 -0
  63. package/docs/200-getting-started/100-install-and-run-overmux.md +51 -0
  64. package/docs/200-getting-started/200-install-overmux-desktop.md +41 -0
  65. package/docs/200-getting-started/300-install-overmux-pwa.mdx +92 -0
  66. package/docs/200-getting-started/400-secure-with-https/100-choose-an-https-setup.md +14 -0
  67. package/docs/200-getting-started/400-secure-with-https/200-tailscale-serve.md +44 -0
  68. package/docs/200-getting-started/400-secure-with-https/300-cloudflare-tunnel.md +59 -0
  69. package/docs/200-getting-started/400-secure-with-https/400-self-hosted-reverse-proxy.md +32 -0
  70. package/docs/300-fundamentals/100-project-structure.md +3 -0
  71. package/docs/300-fundamentals/200-configuration.md +3 -0
  72. package/docs/300-fundamentals/300-theming.md +3 -0
  73. package/docs/300-fundamentals/400-server.md +3 -0
  74. package/docs/300-fundamentals/500-client.md +3 -0
  75. package/docs/300-fundamentals/600-operations.md +3 -0
  76. package/docs/300-fundamentals/700-resources.md +3 -0
  77. package/docs/300-fundamentals/800-streams.md +3 -0
  78. package/docs/300-fundamentals/900-authentication-and-security.md +3 -0
  79. package/docs/400-reference/100-configuration.md +3 -0
  80. package/docs/400-reference/200-server-api.md +3 -0
  81. package/docs/400-reference/300-client-api.md +3 -0
  82. package/docs/400-reference/400-cli/100-serve.md +33 -0
  83. package/docs/400-reference/400-cli/200-auth.md +52 -0
  84. package/docs/400-reference/400-cli/300-call.md +28 -0
  85. package/docs/400-reference/400-cli/400-check.md +20 -0
  86. package/docs/400-reference/400-cli/500-ai-context.md +102 -0
  87. package/docs/400-reference/400-cli/600-docs.md +23 -0
  88. package/docs/400-reference/400-cli/700-desktop.md +35 -0
  89. package/docs/500-hosted-pages.md +31 -0
  90. package/package.json +59 -18
  91. package/src/internal/cli/app.ts +72 -0
  92. package/src/internal/cli/bin.ts +3 -0
  93. package/src/internal/cli/commands/ai.ts +44 -0
  94. package/src/internal/cli/commands/auth.ts +195 -0
  95. package/src/internal/cli/commands/call.ts +128 -0
  96. package/src/internal/cli/commands/check.ts +48 -0
  97. package/src/internal/cli/commands/desktop-installer.ts +446 -0
  98. package/src/internal/cli/commands/desktop-release.ts +190 -0
  99. package/src/internal/cli/commands/desktop.ts +47 -0
  100. package/src/internal/cli/commands/docs.ts +34 -0
  101. package/src/internal/cli/commands/serve.ts +322 -0
  102. package/src/internal/cli/environment.ts +54 -0
  103. package/src/internal/cli/login.ts +46 -0
  104. package/src/internal/cli/managed-vite.ts +267 -0
  105. package/src/internal/cli/parse-port.ts +8 -0
  106. package/src/internal/client/auth/auth-api.ts +53 -0
  107. package/src/internal/client/auth/auth-shell.css +74 -0
  108. package/src/internal/client/auth/auth-shell.tsx +167 -0
  109. package/src/internal/client/auth/entry.tsx +26 -0
  110. package/src/internal/client/auth/index.html +13 -0
  111. package/src/internal/client/background-notifications.ts +194 -0
  112. package/src/internal/client/browser-api.ts +97 -0
  113. package/src/internal/client/client-definition.ts +184 -0
  114. package/src/internal/client/commands.tsx +260 -0
  115. package/src/internal/client/host/browser-log-forwarding.ts +149 -0
  116. package/src/internal/client/host/hosted-pages/browser-notification-settings.tsx +118 -0
  117. package/src/internal/client/host/hosted-pages/hosted-page.css +87 -0
  118. package/src/internal/client/host/hosted-pages/hosted-page.tsx +9 -0
  119. package/src/internal/client/host/hosted-pages/logout-page.tsx +75 -0
  120. package/src/internal/client/host/hosted-pages/settings-page.tsx +45 -0
  121. package/src/internal/client/host/notification-forwarding.ts +22 -0
  122. package/src/internal/client/host/overmux-host.tsx +200 -0
  123. package/src/internal/client/host/recovery-screen.css +75 -0
  124. package/src/internal/client/host/recovery-screen.tsx +17 -0
  125. package/src/internal/client/host/update-popover.css +82 -0
  126. package/src/internal/client/host/update-popover.tsx +166 -0
  127. package/src/internal/client/index.ts +45 -0
  128. package/src/internal/client/overmux-react.ts +283 -0
  129. package/src/internal/client/shortcuts.tsx +373 -0
  130. package/src/internal/client/theme-scope.css +114 -0
  131. package/src/internal/client/theme-scope.tsx +107 -0
  132. package/src/internal/client/transport.ts +69 -0
  133. package/src/internal/client/websocket.ts +441 -0
  134. package/src/internal/server/auth/auth-http.ts +414 -0
  135. package/src/internal/server/auth/auth-service.ts +627 -0
  136. package/src/internal/server/auth/auth-shell-http.ts +90 -0
  137. package/src/internal/server/auth/instance-control.ts +381 -0
  138. package/src/internal/server/check-overmux.ts +364 -0
  139. package/src/internal/server/coordinator/ipc-protocol.ts +44 -0
  140. package/src/internal/server/coordinator/server-child.ts +108 -0
  141. package/src/internal/server/coordinator/server-coordinator.ts +262 -0
  142. package/src/internal/server/coordinator/update-watcher.ts +54 -0
  143. package/src/internal/server/http/authenticated-http-app-test-helper.ts +45 -0
  144. package/src/internal/server/http/background-notification-handlers.ts +83 -0
  145. package/src/internal/server/http/control-handlers.ts +44 -0
  146. package/src/internal/server/http/create-http-app.ts +53 -0
  147. package/src/internal/server/http/development-web-backend.ts +83 -0
  148. package/src/internal/server/http/operation-handler.ts +93 -0
  149. package/src/internal/server/http/request-logging.ts +34 -0
  150. package/src/internal/server/http/routes.ts +241 -0
  151. package/src/internal/server/http/runtime-manifest-handler.ts +12 -0
  152. package/src/internal/server/index.ts +16 -0
  153. package/src/internal/server/notifications/background-notification-payload.ts +74 -0
  154. package/src/internal/server/notifications/background-notification-service.ts +139 -0
  155. package/src/internal/server/notifications/background-notification-store.ts +162 -0
  156. package/src/internal/server/notifications/notification-service.ts +70 -0
  157. package/src/internal/server/notifications/private-json-file.ts +84 -0
  158. package/src/internal/server/paths.ts +13 -0
  159. package/src/internal/server/rate-limiter.ts +58 -0
  160. package/src/internal/server/runtime/create-runtime.ts +87 -0
  161. package/src/internal/server/runtime/resource-dependency-graph.ts +128 -0
  162. package/src/internal/server/runtime/runtime-lifecycle.ts +95 -0
  163. package/src/internal/server/runtime/runtime-operations.ts +78 -0
  164. package/src/internal/server/runtime/runtime-resources.ts +201 -0
  165. package/src/internal/server/runtime/runtime-streams.ts +130 -0
  166. package/src/internal/server/server-logger.ts +110 -0
  167. package/src/internal/server/server-startup-options.ts +51 -0
  168. package/src/internal/server/start-application-server.ts +412 -0
  169. package/src/internal/server/start-overmux-server.ts +26 -0
  170. package/src/internal/server/test-port.ts +23 -0
  171. package/src/internal/server/vite-websocket-proxy.ts +201 -0
  172. package/src/internal/server/websocket-connection.ts +586 -0
  173. package/src/internal/server/websocket-limits.ts +2 -0
  174. package/src/internal/server/websocket-server.ts +311 -0
  175. package/src/internal/shared/background-notifications.ts +37 -0
  176. package/src/internal/shared/index.ts +2 -0
  177. package/src/internal/shared/protocol.ts +317 -0
  178. package/src/internal/shared/routes.ts +3 -0
  179. package/src/public/ai-context.ts +44 -0
  180. package/src/public/client.ts +53 -0
  181. package/src/public/config.ts +287 -0
  182. package/src/public/contracts.ts +55 -0
  183. package/src/public/index.ts +57 -0
  184. package/src/public/notifications.ts +10 -0
  185. package/src/public/server.ts +16 -0
  186. package/dist/public/core.ts +0 -119
  187. package/dist/public/protocol.ts +0 -51
  188. package/dist/public/terminal.ts +0 -23
  189. package/dist/web/assets/index-DC___IUw.css +0 -2
  190. package/dist/web/assets/index-DF9Cptse.js +0 -74
  191. package/dist/web/assets/terminal-component-BrP-ENHg.css +0 -1
  192. package/dist/web/assets/terminal-component-DyLEbMrx.js +0 -36
  193. package/dist/web/index.html +0 -14
@@ -0,0 +1,128 @@
1
+ import { buildCommand } from "@stricli/core";
2
+
3
+ import {
4
+ discoverInstance,
5
+ sendControlRequest,
6
+ type InstanceRegistration,
7
+ } from "../../server/auth/instance-control";
8
+ import { parsePort } from "../parse-port";
9
+
10
+ type Fetch = (input: URL, init: RequestInit) => Promise<Response>;
11
+
12
+ type CallFlags = {
13
+ input?: string;
14
+ port?: string;
15
+ };
16
+
17
+ const localCredential = async (port?: number) => {
18
+ const instance = await discoverInstance(port);
19
+ const response = await sendControlRequest(instance, {
20
+ type: "issue-bearer",
21
+ });
22
+ return { instance, token: response.bearer.token };
23
+ };
24
+
25
+ const getErrorMessage = async (response: Response) => {
26
+ const body = await response.text();
27
+ if (!body) {
28
+ return `Operation request failed: ${response.status} ${response.statusText}`;
29
+ }
30
+ try {
31
+ const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
32
+ const message = parsed.error ?? parsed.message;
33
+ if (typeof message === "string") {
34
+ return `Operation request failed: ${message}`;
35
+ }
36
+ } catch {}
37
+ return `Operation request failed: ${body}`;
38
+ };
39
+
40
+ export const executeCallCommand = async ({
41
+ input,
42
+ operationName,
43
+ fetch = globalThis.fetch,
44
+ getLocalCredential = localCredential,
45
+ port,
46
+ }: {
47
+ input?: string;
48
+ operationName: string;
49
+ fetch?: Fetch;
50
+ getLocalCredential?: (port?: number) => Promise<{
51
+ instance: InstanceRegistration;
52
+ token: string;
53
+ }>;
54
+ port?: number;
55
+ }): Promise<unknown> => {
56
+ let parsedInput: unknown;
57
+ if (input !== undefined) {
58
+ try {
59
+ parsedInput = JSON.parse(input) as unknown;
60
+ } catch {
61
+ throw new Error("Operation input must be valid JSON");
62
+ }
63
+ }
64
+ const credential = await getLocalCredential(port);
65
+ const url = new URL(
66
+ `/api/operations/${encodeURIComponent(operationName)}`,
67
+ credential.instance.apiUrl,
68
+ );
69
+ const response = await fetch(url, {
70
+ body: input === undefined ? undefined : JSON.stringify(parsedInput),
71
+ headers: {
72
+ authorization: `Bearer ${credential.token}`,
73
+ "content-type": "application/json",
74
+ },
75
+ method: "POST",
76
+ });
77
+ if (!response.ok) {
78
+ throw new Error(await getErrorMessage(response));
79
+ }
80
+ return response.status === 204 ? undefined : response.json();
81
+ };
82
+
83
+ const runCall = async (flags: CallFlags, operationName: string) => {
84
+ const port = flags.port === undefined ? undefined : parsePort(flags.port);
85
+ if (port instanceof Error) {
86
+ return port;
87
+ }
88
+ const output = await executeCallCommand({
89
+ input: flags.input,
90
+ operationName,
91
+ port,
92
+ });
93
+ if (output !== undefined) {
94
+ console.log(JSON.stringify(output));
95
+ }
96
+ };
97
+
98
+ export const callCommand = buildCommand({
99
+ func: runCall,
100
+ parameters: {
101
+ aliases: { i: "input", p: "port" },
102
+ flags: {
103
+ input: {
104
+ brief: "Operation input as JSON",
105
+ kind: "parsed",
106
+ optional: true,
107
+ parse: String,
108
+ },
109
+ port: {
110
+ brief: "Running local server port",
111
+ kind: "parsed",
112
+ optional: true,
113
+ parse: String,
114
+ },
115
+ },
116
+ positional: {
117
+ kind: "tuple",
118
+ parameters: [
119
+ {
120
+ brief: "Operation name",
121
+ parse: String,
122
+ placeholder: "operation-name",
123
+ },
124
+ ],
125
+ },
126
+ },
127
+ docs: { brief: "Invoke an operation on a running Overmux server" },
128
+ });
@@ -0,0 +1,48 @@
1
+ import { buildCommand } from "@stricli/core";
2
+ import { checkOvermux, type CheckResult } from "../../server/index";
3
+ import { getDefaultConfigPath } from "../../server/paths";
4
+ type CheckFlags = {
5
+ config: string;
6
+ };
7
+
8
+ const printCheck = (result: CheckResult) => {
9
+ result.diagnostics.forEach((diagnostic) => {
10
+ const location = diagnostic.file
11
+ ? `${diagnostic.file}:${diagnostic.line ?? 1}:${diagnostic.column ?? 1}`
12
+ : (diagnostic.path ?? diagnostic.phase);
13
+ const code = diagnostic.code ? ` ${diagnostic.code}` : "";
14
+ console.error(`error${code} ${location}\n ${diagnostic.message}`);
15
+ });
16
+ if (!result.ok) {
17
+ console.error(`check failed: ${result.diagnostics.length} errors`);
18
+ return;
19
+ }
20
+ console.log(`✓ config: ${result.config}`);
21
+ if (result.operations !== undefined) {
22
+ console.log(`✓ operations: ${result.operations} definitions`);
23
+ }
24
+ console.log(`✓ types: ${result.files} reachable files`);
25
+ };
26
+
27
+ const runCheck = async (flags: CheckFlags) => {
28
+ const result = await checkOvermux({ configPath: flags.config });
29
+ printCheck(result);
30
+ process.exitCode = result.ok ? 0 : 1;
31
+ };
32
+
33
+ export const checkCommand = buildCommand({
34
+ func: runCheck,
35
+ parameters: {
36
+ aliases: { c: "config" },
37
+ flags: {
38
+ config: {
39
+ brief: "Configuration file",
40
+ default: getDefaultConfigPath(),
41
+ kind: "parsed",
42
+ parse: String,
43
+ placeholder: "path",
44
+ },
45
+ },
46
+ },
47
+ docs: { brief: "Validate an Overmux configuration" },
48
+ });
@@ -0,0 +1,446 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readlinkSync,
8
+ readdirSync,
9
+ renameSync,
10
+ rmSync,
11
+ } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
14
+ import { createInterface } from "node:readline/promises";
15
+
16
+ import {
17
+ downloadTextAsset,
18
+ downloadVerifiedAsset,
19
+ findDesktopRelease,
20
+ parseSha256Sums,
21
+ type DesktopArchitecture,
22
+ type DesktopRelease,
23
+ } from "./desktop-release";
24
+
25
+ export type DesktopAction = "install" | "upgrade";
26
+
27
+ type CommandResult = { status: number | null; stderr: string; stdout: string };
28
+ export type DesktopInstallerDependencies = {
29
+ arch: string;
30
+ confirm: () => Promise<boolean>;
31
+ downloadText: (asset: DesktopRelease["checksums"]) => Promise<string>;
32
+ downloadVerified: typeof downloadVerifiedAsset;
33
+ findRelease: (architecture: DesktopArchitecture) => Promise<DesktopRelease>;
34
+ homeDirectory: string;
35
+ platform: string;
36
+ runCommand: (command: string, args: readonly string[]) => CommandResult;
37
+ sleep: (milliseconds: number) => Promise<void>;
38
+ stderr: Pick<NodeJS.WriteStream, "write">;
39
+ stdout: Pick<NodeJS.WriteStream, "write">;
40
+ };
41
+
42
+ type DesktopInstallOptions = {
43
+ action: DesktopAction;
44
+ yes?: boolean;
45
+ };
46
+
47
+ const bundleIdentifier = "com.overmux.desktop";
48
+ const bundleName = "Overmux.app";
49
+
50
+ const runCommand = (
51
+ command: string,
52
+ args: readonly string[],
53
+ ): CommandResult => {
54
+ const result = spawnSync(command, args, { encoding: "utf8" });
55
+ if (result.error) {
56
+ throw new Error(`Could not run ${command}`, { cause: result.error });
57
+ }
58
+ return {
59
+ status: result.status,
60
+ stderr: result.stderr ?? "",
61
+ stdout: result.stdout ?? "",
62
+ };
63
+ };
64
+
65
+ const requireCommand = (
66
+ dependencies: DesktopInstallerDependencies,
67
+ command: string,
68
+ args: readonly string[],
69
+ ) => {
70
+ const result = dependencies.runCommand(command, args);
71
+ if (result.status !== 0) {
72
+ throw new Error(
73
+ `${basename(command)} failed: ${result.stderr.trim() || "unknown error"}`,
74
+ );
75
+ }
76
+ return result.stdout.trim();
77
+ };
78
+
79
+ const sleep = (milliseconds: number) =>
80
+ new Promise<void>((resolve) => {
81
+ setTimeout(resolve, milliseconds);
82
+ });
83
+
84
+ const confirmUnsignedInstall = async (process: NodeJS.Process) => {
85
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
86
+ throw new Error(
87
+ "Unsigned desktop installation requires an interactive terminal; rerun with --yes to accept the risk",
88
+ );
89
+ }
90
+ process.stdout.write(
91
+ "Overmux Desktop for macOS is experimental, unsigned, and not notarized. macOS cannot verify its publisher.\n",
92
+ );
93
+ const prompt = createInterface({
94
+ input: process.stdin,
95
+ output: process.stdout,
96
+ });
97
+ const answer = await prompt.question(
98
+ "Install this unsigned application? [y/N] ",
99
+ );
100
+ prompt.close();
101
+ return /^(?:y|yes)$/i.test(answer.trim());
102
+ };
103
+
104
+ const createDependencies = (
105
+ process: NodeJS.Process,
106
+ ): DesktopInstallerDependencies => ({
107
+ arch: process.arch,
108
+ confirm: () => confirmUnsignedInstall(process),
109
+ downloadText: downloadTextAsset,
110
+ downloadVerified: downloadVerifiedAsset,
111
+ findRelease: findDesktopRelease,
112
+ homeDirectory: homedir(),
113
+ platform: process.platform,
114
+ runCommand,
115
+ sleep,
116
+ stderr: process.stderr,
117
+ stdout: process.stdout,
118
+ });
119
+
120
+ const getArchitecture = (arch: string): DesktopArchitecture => {
121
+ if (arch === "arm64" || arch === "x64") {
122
+ return arch;
123
+ }
124
+ throw new Error(`Overmux Desktop does not provide a macOS ${arch} build`);
125
+ };
126
+
127
+ const assertApplicationsDirectory = (path: string) => {
128
+ if (!existsSync(path)) {
129
+ return;
130
+ }
131
+ const metadata = lstatSync(path);
132
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
133
+ throw new Error(`Refusing unsafe user Applications directory: ${path}`);
134
+ }
135
+ };
136
+
137
+ export const validateDesktopZipEntries = (entries: readonly string[]) => {
138
+ if (entries.length === 0) {
139
+ throw new Error("Desktop ZIP is empty");
140
+ }
141
+ entries.forEach((entry) => {
142
+ const path = entry.endsWith("/") ? entry.slice(0, -1) : entry;
143
+ const segments = path.split("/");
144
+ if (
145
+ !path ||
146
+ path.includes("\\") ||
147
+ path.includes("\0") ||
148
+ isAbsolute(path) ||
149
+ segments.some(
150
+ (segment) => !segment || segment === "." || segment === "..",
151
+ ) ||
152
+ segments[0] !== bundleName
153
+ ) {
154
+ throw new Error(`Desktop ZIP contains an unsafe path: ${entry}`);
155
+ }
156
+ });
157
+ };
158
+
159
+ const assertSafeBundleTree = (bundlePath: string, directory = bundlePath) => {
160
+ readdirSync(directory).forEach((name) => {
161
+ const path = resolve(directory, name);
162
+ const metadata = lstatSync(path);
163
+ if (metadata.isSymbolicLink()) {
164
+ const target = readlinkSync(path);
165
+ const resolvedTarget = resolve(dirname(path), target);
166
+ const targetRelative = relative(bundlePath, resolvedTarget);
167
+ if (isAbsolute(target) || targetRelative.startsWith("..")) {
168
+ throw new Error(
169
+ `Desktop bundle contains an unsafe symbolic link: ${path}`,
170
+ );
171
+ }
172
+ return;
173
+ }
174
+ if (metadata.isDirectory()) {
175
+ assertSafeBundleTree(bundlePath, path);
176
+ return;
177
+ }
178
+ if (!metadata.isFile()) {
179
+ throw new Error(`Desktop bundle contains an unsupported file: ${path}`);
180
+ }
181
+ });
182
+ };
183
+
184
+ export const verifyDesktopBundle = (
185
+ bundlePath: string,
186
+ dependencies: Pick<DesktopInstallerDependencies, "runCommand">,
187
+ ) => {
188
+ const metadata = lstatSync(bundlePath);
189
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
190
+ throw new Error(`${bundlePath} is not an application bundle directory`);
191
+ }
192
+ assertSafeBundleTree(bundlePath);
193
+ const infoPath = resolve(bundlePath, "Contents/Info.plist");
194
+ const fields = [
195
+ ["CFBundleIdentifier", bundleIdentifier],
196
+ ["CFBundleExecutable", "Overmux"],
197
+ ["CFBundlePackageType", "APPL"],
198
+ ] as const;
199
+ fields.forEach(([field, expected]) => {
200
+ const result = dependencies.runCommand("/usr/libexec/PlistBuddy", [
201
+ "-c",
202
+ `Print:${field}`,
203
+ infoPath,
204
+ ]);
205
+ if (result.status !== 0 || result.stdout.trim() !== expected) {
206
+ throw new Error(`Refusing application bundle with invalid ${field}`);
207
+ }
208
+ });
209
+ };
210
+
211
+ const extractAndVerifyBundle = ({
212
+ archivePath,
213
+ dependencies,
214
+ stagingDirectory,
215
+ }: {
216
+ archivePath: string;
217
+ dependencies: DesktopInstallerDependencies;
218
+ stagingDirectory: string;
219
+ }) => {
220
+ const listing = requireCommand(dependencies, "/usr/bin/unzip", [
221
+ "-Z1",
222
+ archivePath,
223
+ ]);
224
+ validateDesktopZipEntries(listing.split(/\r?\n/));
225
+ const extractionDirectory = resolve(stagingDirectory, "extracted");
226
+ mkdirSync(extractionDirectory);
227
+ requireCommand(dependencies, "/usr/bin/ditto", [
228
+ "-x",
229
+ "-k",
230
+ archivePath,
231
+ extractionDirectory,
232
+ ]);
233
+ const topLevelEntries = readdirSync(extractionDirectory);
234
+ if (topLevelEntries.length !== 1 || topLevelEntries[0] !== bundleName) {
235
+ throw new Error("Desktop ZIP must contain only Overmux.app");
236
+ }
237
+ const bundlePath = resolve(extractionDirectory, bundleName);
238
+ verifyDesktopBundle(bundlePath, dependencies);
239
+ return bundlePath;
240
+ };
241
+
242
+ const prepareSignature = (
243
+ bundlePath: string,
244
+ dependencies: DesktopInstallerDependencies,
245
+ ) => {
246
+ const verification = dependencies.runCommand("/usr/bin/codesign", [
247
+ "--verify",
248
+ "--deep",
249
+ "--strict",
250
+ bundlePath,
251
+ ]);
252
+ if (verification.status !== 0) {
253
+ requireCommand(dependencies, "/usr/bin/codesign", [
254
+ "--force",
255
+ "--deep",
256
+ "--sign",
257
+ "-",
258
+ bundlePath,
259
+ ]);
260
+ }
261
+ requireCommand(dependencies, "/usr/bin/codesign", [
262
+ "--verify",
263
+ "--deep",
264
+ "--strict",
265
+ bundlePath,
266
+ ]);
267
+ const quarantineRemoval = dependencies.runCommand("/usr/bin/xattr", [
268
+ "-dr",
269
+ "com.apple.quarantine",
270
+ bundlePath,
271
+ ]);
272
+ if (quarantineRemoval.status !== 0 && quarantineRemoval.status !== 1) {
273
+ throw new Error(
274
+ `Could not remove quarantine: ${quarantineRemoval.stderr.trim()}`,
275
+ );
276
+ }
277
+ };
278
+
279
+ const waitForDesktopExit = async (
280
+ dependencies: DesktopInstallerDependencies,
281
+ attemptsRemaining: number,
282
+ ): Promise<void> => {
283
+ const result = dependencies.runCommand("/usr/bin/pgrep", ["-x", "Overmux"]);
284
+ if (result.status === 1) {
285
+ return;
286
+ }
287
+ if (result.status !== 0) {
288
+ throw new Error(
289
+ `Could not confirm that Overmux quit: ${result.stderr.trim()}`,
290
+ );
291
+ }
292
+ if (attemptsRemaining === 0) {
293
+ throw new Error("Overmux did not quit; close it and retry the upgrade");
294
+ }
295
+ await dependencies.sleep(100);
296
+ return waitForDesktopExit(dependencies, attemptsRemaining - 1);
297
+ };
298
+
299
+ const stopRunningDesktop = async (
300
+ dependencies: DesktopInstallerDependencies,
301
+ ) => {
302
+ const running = dependencies.runCommand("/usr/bin/pgrep", ["-x", "Overmux"]);
303
+ if (running.status === 1) {
304
+ return;
305
+ }
306
+ if (running.status !== 0) {
307
+ throw new Error(
308
+ `Could not determine whether Overmux is running: ${running.stderr.trim()}`,
309
+ );
310
+ }
311
+ requireCommand(dependencies, "/usr/bin/osascript", [
312
+ "-e",
313
+ `tell application id "${bundleIdentifier}" to quit`,
314
+ ]);
315
+ await waitForDesktopExit(dependencies, 50);
316
+ };
317
+
318
+ const launchDesktop = (
319
+ destination: string,
320
+ dependencies: DesktopInstallerDependencies,
321
+ ) => requireCommand(dependencies, "/usr/bin/open", [destination]);
322
+
323
+ const replaceDesktop = async ({
324
+ action,
325
+ bundlePath,
326
+ dependencies,
327
+ destination,
328
+ stagingDirectory,
329
+ }: {
330
+ action: DesktopAction;
331
+ bundlePath: string;
332
+ dependencies: DesktopInstallerDependencies;
333
+ destination: string;
334
+ stagingDirectory: string;
335
+ }) => {
336
+ const backupPath = resolve(stagingDirectory, "Overmux.previous.app");
337
+ if (action === "upgrade") {
338
+ verifyDesktopBundle(destination, dependencies);
339
+ await stopRunningDesktop(dependencies);
340
+ renameSync(destination, backupPath);
341
+ } else if (existsSync(destination)) {
342
+ throw new Error(
343
+ `${destination} appeared during installation; no files were replaced`,
344
+ );
345
+ }
346
+ try {
347
+ renameSync(bundlePath, destination);
348
+ launchDesktop(destination, dependencies);
349
+ } catch (error) {
350
+ rmSync(destination, { force: true, recursive: true });
351
+ if (action === "upgrade" && existsSync(backupPath)) {
352
+ renameSync(backupPath, destination);
353
+ try {
354
+ launchDesktop(destination, dependencies);
355
+ } catch {
356
+ dependencies.stderr.write(
357
+ "Restored the previous Overmux.app but could not relaunch it.\n",
358
+ );
359
+ }
360
+ }
361
+ throw error;
362
+ }
363
+ };
364
+
365
+ export const installDesktop = async (
366
+ options: DesktopInstallOptions,
367
+ overrides: Partial<DesktopInstallerDependencies> = {},
368
+ process = globalThis.process,
369
+ ) => {
370
+ const dependencies = { ...createDependencies(process), ...overrides };
371
+ if (dependencies.platform !== "darwin") {
372
+ throw new Error(
373
+ "Overmux desktop install and upgrade are only available on macOS",
374
+ );
375
+ }
376
+ const architecture = getArchitecture(dependencies.arch);
377
+ const applicationsDirectory = resolve(
378
+ dependencies.homeDirectory,
379
+ "Applications",
380
+ );
381
+ assertApplicationsDirectory(applicationsDirectory);
382
+ const destination = resolve(applicationsDirectory, bundleName);
383
+ const exists = existsSync(destination);
384
+ if (options.action === "install" && exists) {
385
+ throw new Error(
386
+ `${destination} already exists; run overmux desktop upgrade`,
387
+ );
388
+ }
389
+ if (options.action === "upgrade" && !exists) {
390
+ throw new Error(
391
+ `${destination} is not installed; run overmux desktop install`,
392
+ );
393
+ }
394
+ if (options.action === "upgrade") {
395
+ verifyDesktopBundle(destination, dependencies);
396
+ }
397
+ if (
398
+ options.action === "install" &&
399
+ !options.yes &&
400
+ !(await dependencies.confirm())
401
+ ) {
402
+ throw new Error("Desktop installation cancelled");
403
+ }
404
+
405
+ mkdirSync(applicationsDirectory, { recursive: true });
406
+ assertApplicationsDirectory(applicationsDirectory);
407
+ const stagingDirectory = mkdtempSync(
408
+ resolve(applicationsDirectory, ".overmux-install-"),
409
+ );
410
+ try {
411
+ const release = await dependencies.findRelease(architecture);
412
+ const checksums = parseSha256Sums(
413
+ await dependencies.downloadText(release.checksums),
414
+ );
415
+ const expectedSha256 = checksums.get(release.artifact.name);
416
+ if (!expectedSha256) {
417
+ throw new Error(
418
+ `${release.tag} SHA256SUMS does not contain ${release.artifact.name}`,
419
+ );
420
+ }
421
+ const archivePath = resolve(stagingDirectory, release.artifact.name);
422
+ await dependencies.downloadVerified({
423
+ asset: release.artifact,
424
+ destination: archivePath,
425
+ expectedSha256,
426
+ });
427
+ const bundlePath = extractAndVerifyBundle({
428
+ archivePath,
429
+ dependencies,
430
+ stagingDirectory,
431
+ });
432
+ prepareSignature(bundlePath, dependencies);
433
+ await replaceDesktop({
434
+ action: options.action,
435
+ bundlePath,
436
+ dependencies,
437
+ destination,
438
+ stagingDirectory,
439
+ });
440
+ dependencies.stdout.write(
441
+ `${options.action === "install" ? "Installed" : "Upgraded"} and launched ${destination} (${release.version}).\n`,
442
+ );
443
+ } finally {
444
+ rmSync(stagingDirectory, { force: true, recursive: true });
445
+ }
446
+ };