-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Move iOS app into repo with public-only Convex sync and proprietary licensing #367
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
82cb131
56f8037
4d78a01
c052f67
7b5f2b7
0cc1cbf
7280c82
3997738
033252b
9debe35
b6e5dbc
a246726
5f5cdd7
cd848c0
6afe13b
4a4eefb
70656f7
4e7b732
6b905db
06f8d81
e1d5e70
ca1252c
0428c93
d35e9cd
53c2e9f
a70a36d
4541919
1332e3c
0a62d6f
59703af
e1830f6
93f29a3
f8f6bbb
d0d3bdd
520b4ed
953557b
aeb40a3
b908e5f
66324d5
d0afa9a
496fbe2
5d07362
f2b055b
797da21
4217092
0e38b3a
9688324
5695aa9
b7497e8
150fa3d
7c6a4b0
0948181
00d2d5a
7b2c504
ad3c334
8284691
8e3afd5
cadf01c
cbaa5e1
06e3ed6
71d876d
d1121e1
6ce2c7d
43d5830
b2cacff
274af05
5826954
8ebe743
22e1d61
f848c99
aa0e98b
71727ed
8e25526
7a20839
17b1333
3b9b766
a16709e
2e5da2f
2557476
e1de296
164f281
dc7f441
08b6e39
5973bbe
b777bc9
63b9200
fd23931
60670b1
1e93e44
f6a1d61
7c9f86f
d983b57
47dd639
a524077
10fed42
801f47c
fb428f6
110d3c6
36e1cff
057f5bb
2f0a112
598a41c
42a3e00
46fb971
aed0e7b
0f6b2a7
d22c813
db3db01
9a4fc2f
9fbf6c3
047f74b
f5d1bfc
6ad55e0
dd0bcd2
d3d99f9
64ab3a9
213964d
ad30e50
18fe4fa
1ac3801
d20fe1b
5464a5f
533ae1e
32b9a25
efcba9d
e4607bc
b0084e1
6ea8932
56327c0
0190346
bfbd93d
bd52403
86a66ca
0de127e
749c7b8
ca28f15
d138a63
8e73df0
646de9b
2e3cc4b
a4dc14a
9bf0466
d918362
8f30127
fb39b20
7dd37e0
c8790b3
1451fd3
490ec92
4c12077
4d57a27
aee65d4
3f71afc
4e8ed2f
39774a6
9444728
3a8b267
47a8235
c383797
295b4b2
94d5456
ef80b04
a7697ca
f430286
18a70b4
373e253
0b7bb52
9619698
87d5a10
91f335d
597c7f3
a1a63f4
f10ccf3
7682476
0b87a3f
199426c
e8c7313
400236a
fc89fc1
f7d37bc
23ff44a
82c94d0
e47031c
c8b634d
a5d425a
0950c44
81b7e55
df537d2
f4c610a
396ebcd
8f089f4
71c9743
c503f81
e6c7798
97ad01a
94134ee
4017899
4a4ca73
9202fe5
45b8b34
e81e53b
77ff67d
d6949c7
ee0e39a
0bb48e1
8ff6260
0dc7d56
dfc88dd
51fb525
4a21acb
0189bb5
a160c5b
804510c
54ecf65
b39a8c0
1094102
c47591f
54007d7
98765d3
89c4c87
ba459db
1bcafeb
eea2fcf
e4c81be
77c0246
0ad537f
d0d05a7
ef85bc5
8a6f9f4
30c2c87
e525cfd
fa33d48
58d5da5
5635f4d
6f6f65f
19f49b0
36f5003
9b58e3e
8582615
41af789
f80a062
ffc8a32
7a1430a
0feb004
e370ca4
2d13c04
984aae5
cab80d1
cbafa76
135bceb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -49,6 +49,22 @@ jobs: | |
| with: | ||
| go-version-file: daemon/remote/go.mod | ||
|
|
||
| - name: Install zig | ||
| run: | | ||
| set -euo pipefail | ||
| ZIG_REQUIRED="0.15.2" | ||
| if command -v zig >/dev/null 2>&1 && zig version 2>/dev/null | grep -q "^${ZIG_REQUIRED}"; then | ||
| echo "zig ${ZIG_REQUIRED} already installed" | ||
| else | ||
| echo "Installing zig ${ZIG_REQUIRED} from tarball" | ||
| curl -fSL "https://ziglang.org/download/${ZIG_REQUIRED}/zig-x86_64-linux-${ZIG_REQUIRED}.tar.xz" -o /tmp/zig.tar.xz | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Add integrity verification (checksum/signature) for the Zig tarball before extracting and installing it. Prompt for AI agents |
||
| tar xf /tmp/zig.tar.xz -C /tmp | ||
| sudo mv /tmp/zig-x86_64-linux-${ZIG_REQUIRED}/zig /usr/local/bin/zig | ||
| sudo rm -rf /usr/local/lib/zig | ||
| sudo mv /tmp/zig-x86_64-linux-${ZIG_REQUIRED}/lib /usr/local/lib/zig | ||
| zig version | ||
| fi | ||
|
|
||
| - name: Run remote daemon tests | ||
| working-directory: daemon/remote | ||
| run: go test ./... | ||
|
|
||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1770,6 +1770,11 @@ struct CMUXCLI { | |
| return | ||
| } | ||
|
|
||
| if command == "login" { | ||
| try runLogin(commandArgs: commandArgs, socketPath: resolvedSocketPath, explicitPassword: socketPasswordArg) | ||
| return | ||
| } | ||
|
|
||
| if command == "claude-teams" { | ||
| try runClaudeTeams( | ||
| commandArgs: commandArgs, | ||
|
|
@@ -1922,6 +1927,41 @@ struct CMUXCLI { | |
| let response = try client.sendV2(method: "system.identify", params: params) | ||
| print(jsonString(formatIDs(response, mode: idFormat))) | ||
|
|
||
| case "daemon-status", "daemon": | ||
| let subcommand = commandArgs.first?.lowercased() ?? "status" | ||
| if subcommand == "stop" { | ||
| let response = try client.sendV2(method: "daemon.stop") | ||
| if jsonOutput { | ||
| print(jsonString(response)) | ||
| } else { | ||
| print("Daemon stopped.") | ||
| } | ||
| } else { | ||
| let response = try client.sendV2(method: "daemon.status") | ||
| if jsonOutput { | ||
| print(jsonString(response)) | ||
| } else { | ||
| if let bridge = response["bridge"] as? [String: Any] { | ||
| let status = bridge["status"] as? String ?? "unknown" | ||
| let socketPath = bridge["socket_path"] as? String ?? "unknown" | ||
| let syncCount = bridge["sync_count"] as? Int ?? 0 | ||
| let lastSync = bridge["last_sync"] as? String ?? "never" | ||
| print("Bridge: \(status)") | ||
| print(" Socket: \(socketPath)") | ||
| print(" Syncs: \(syncCount)") | ||
| print(" Last sync: \(lastSync)") | ||
| } | ||
| if let daemon = response["daemon"] as? [String: Any] { | ||
| let running = daemon["running"] as? Bool ?? false | ||
| let wsPort = daemon["ws_port"] as? Int | ||
| let daemonSocket = daemon["socket_path"] as? String | ||
| print("Daemon: \(running ? "running" : "stopped")") | ||
| if let wsPort { print(" WebSocket port: \(wsPort)") } | ||
| if let daemonSocket { print(" Socket: \(daemonSocket)") } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| case "list-windows": | ||
| let response = try sendV1Command("list_windows", client: client) | ||
| if jsonOutput { | ||
|
|
@@ -2949,6 +2989,189 @@ struct CMUXCLI { | |
| try activateApp() | ||
| } | ||
|
|
||
| // MARK: - Login (Stack Auth CLI flow) | ||
|
|
||
| private func runLogin(commandArgs: [String], socketPath: String, explicitPassword: String?) throws { | ||
| let noOpen = hasFlag(commandArgs, name: "--no-open") | ||
|
|
||
| // Resolve Stack Auth config from environment (mirrors AuthEnvironment.swift) | ||
| let env = ProcessInfo.processInfo.environment | ||
| let stackBaseURL = env["CMUX_STACK_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "https://api.stack-auth.com" | ||
| #if DEBUG | ||
| let devProjectID = "1467bed0-8522-45ee-a8d8-055de324118c" | ||
| let devClientKey = "pck_pt4nwry6sdskews2pxk4g2fbe861ak2zvaf3mqendspa0" | ||
| #else | ||
| let devProjectID = "8a877114-b905-47c5-8b64-3a2d90679577" | ||
| let devClientKey = "pck_pqghntgd942k1hg066m7htjakb8g4ybaj66hqj2g2frj0" | ||
| #endif | ||
| let projectID = env["CMUX_STACK_PROJECT_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? devProjectID | ||
| let clientKey = env["CMUX_STACK_PUBLISHABLE_CLIENT_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? devClientKey | ||
|
|
||
| // Resolve the handler URL (where the browser opens). | ||
| let handlerOrigin: String | ||
| if let override = env["CMUX_AUTH_WWW_ORIGIN"]?.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !override.isEmpty { | ||
| handlerOrigin = override | ||
| } else { | ||
| #if DEBUG | ||
| let cmuxPort = env["CMUX_PORT"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "3000" | ||
| handlerOrigin = "http://localhost:\(cmuxPort)" | ||
| #else | ||
| handlerOrigin = "https://cmux.com" | ||
| #endif | ||
| } | ||
|
|
||
| // Step 1: Initiate CLI auth session | ||
| let initURL = URL(string: "\(stackBaseURL)/api/v1/auth/cli")! | ||
| var initRequest = URLRequest(url: initURL) | ||
| initRequest.httpMethod = "POST" | ||
| initRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
| initRequest.setValue(projectID, forHTTPHeaderField: "x-stack-project-id") | ||
| initRequest.setValue(clientKey, forHTTPHeaderField: "x-stack-publishable-client-key") | ||
| initRequest.setValue("client", forHTTPHeaderField: "x-stack-access-type") | ||
| initRequest.httpBody = try JSONSerialization.data(withJSONObject: [ | ||
| "expires_in_millis": 7_200_000, // 2 hours | ||
| ]) | ||
|
|
||
| let initResult = try syncHTTPRequest(initRequest) | ||
| guard let initJSON = initResult as? [String: Any], | ||
| let pollingCode = initJSON["polling_code"] as? String, | ||
| let loginCode = initJSON["login_code"] as? String else { | ||
| throw CLIError(message: "login: failed to initiate auth session") | ||
| } | ||
|
|
||
| // Step 2: Open browser (or print URL) | ||
| let confirmURL = "\(handlerOrigin)/handler/cli-auth-confirm?login_code=\(loginCode)" | ||
| if noOpen { | ||
| cliPrint("Open this URL to sign in:") | ||
| cliPrint(confirmURL) | ||
| } else { | ||
| cliPrint("Opening browser for sign-in...") | ||
| let proc = Process() | ||
| proc.executableURL = URL(fileURLWithPath: "/usr/bin/open") | ||
| // Open in Safari explicitly to avoid cmux's built-in browser intercepting | ||
| proc.arguments = ["-a", "Safari", confirmURL] | ||
| try proc.run() | ||
| proc.waitUntilExit() | ||
| } | ||
|
|
||
| // Step 3: Poll for completion | ||
| cliPrint("Waiting for sign-in to complete...") | ||
| let pollURL = URL(string: "\(stackBaseURL)/api/v1/auth/cli/poll")! | ||
| let pollBody = try JSONSerialization.data(withJSONObject: [ | ||
| "polling_code": pollingCode, | ||
| ]) | ||
|
|
||
| let startTime = Date() | ||
| let timeout: TimeInterval = 300 // 5 minutes | ||
| while Date().timeIntervalSince(startTime) < timeout { | ||
| Thread.sleep(forTimeInterval: 2.0) | ||
|
|
||
| var pollRequest = URLRequest(url: pollURL) | ||
| pollRequest.httpMethod = "POST" | ||
| pollRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
| pollRequest.setValue(projectID, forHTTPHeaderField: "x-stack-project-id") | ||
| pollRequest.setValue(clientKey, forHTTPHeaderField: "x-stack-publishable-client-key") | ||
| pollRequest.setValue("client", forHTTPHeaderField: "x-stack-access-type") | ||
| pollRequest.httpBody = pollBody | ||
|
|
||
| guard let pollJSON = try? syncHTTPRequest(pollRequest) as? [String: Any], | ||
| let status = pollJSON["status"] as? String else { | ||
| continue | ||
| } | ||
|
|
||
| switch status { | ||
| case "success": | ||
| guard let refreshToken = pollJSON["refresh_token"] as? String else { | ||
| throw CLIError(message: "login: got success but no refresh token") | ||
| } | ||
| // Send token to the running app via socket | ||
| try seedTokenViaSocket(refreshToken: refreshToken, socketPath: socketPath, explicitPassword: explicitPassword) | ||
| cliPrint("OK Signed in successfully.") | ||
|
Comment on lines
+3088
to
+3090
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
After the browser flow succeeds, the CLI only forwards the refresh token to Useful? React with 👍 / 👎. |
||
| return | ||
| case "expired": | ||
| throw CLIError(message: "login: session expired. Run `cmux login` again.") | ||
| case "used": | ||
| throw CLIError(message: "login: session already used.") | ||
| case "waiting": | ||
| continue | ||
| default: | ||
| continue | ||
| } | ||
| } | ||
|
|
||
| throw CLIError(message: "login: timed out waiting for sign-in (5 minutes)") | ||
| } | ||
|
|
||
| private func cliPrint(_ message: String) { | ||
| fputs(message + "\n", stdout) | ||
| fflush(stdout) | ||
| } | ||
|
|
||
| private func syncHTTPRequest(_ request: URLRequest) throws -> Any { | ||
| let semaphore = DispatchSemaphore(value: 0) | ||
| var responseData: Data? | ||
| var responseError: Error? | ||
|
|
||
| let task = URLSession.shared.dataTask(with: request) { data, _, error in | ||
| responseData = data | ||
| responseError = error | ||
| semaphore.signal() | ||
| } | ||
| task.resume() | ||
| semaphore.wait() | ||
|
|
||
| if let error = responseError { | ||
| throw CLIError(message: "login: network error: \(error.localizedDescription)") | ||
| } | ||
| guard let data = responseData else { | ||
| throw CLIError(message: "login: empty response") | ||
| } | ||
| return try JSONSerialization.jsonObject(with: data) | ||
| } | ||
|
|
||
| private func seedTokenViaSocket(refreshToken: String, socketPath: String, explicitPassword: String?) throws { | ||
| let client = SocketClient(path: socketPath) | ||
| try client.connect() | ||
| defer { client.close() } | ||
| try? authenticateClientIfNeeded(client, explicitPassword: explicitPassword, socketPath: socketPath) | ||
| let result = try client.sendV2(method: "account.seed_tokens", params: [ | ||
| "refresh_token": refreshToken, | ||
| ]) | ||
| if let error = result["error"] as? [String: Any], | ||
| let message = error["message"] as? String { | ||
| throw CLIError(message: "login: app rejected token: \(message)") | ||
| } | ||
| } | ||
|
|
||
| private func storeAuthToken(refreshToken: String, projectID: String) throws { | ||
| // Use the same keychain service and account names as the app's KeychainStackTokenStore | ||
| let service = "com.cmuxterm.app.auth" | ||
| let account = "cmux-auth-refresh-token" | ||
| let tokenData = refreshToken.data(using: .utf8)! | ||
|
|
||
| // Delete existing | ||
| let deleteQuery: [String: Any] = [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: account, | ||
| ] | ||
| SecItemDelete(deleteQuery as CFDictionary) | ||
|
|
||
| // Add new | ||
| let addQuery: [String: Any] = [ | ||
| kSecClass as String: kSecClassGenericPassword, | ||
| kSecAttrService as String: service, | ||
| kSecAttrAccount as String: account, | ||
| kSecValueData as String: tokenData, | ||
| kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked, | ||
| ] | ||
| let status = SecItemAdd(addQuery as CFDictionary, nil) | ||
| guard status == errSecSuccess else { | ||
| throw CLIError(message: "login: failed to store token in keychain (error \(status))") | ||
| } | ||
| } | ||
|
|
||
| private func runFeedback( | ||
| commandArgs: [String], | ||
| socketPath: String, | ||
|
|
@@ -6696,6 +6919,20 @@ struct CMUXCLI { | |
| /// Return the help/usage text for a subcommand, or nil if the command is unknown. | ||
| private func subcommandUsage(_ command: String) -> String? { | ||
| switch command { | ||
| case "daemon", "daemon-status": | ||
| return """ | ||
| Usage: cmux daemon [status|stop] [--json] | ||
|
|
||
| Show the sync daemon status (bridge connection, WebSocket port, sync count). | ||
|
|
||
| Subcommands: | ||
| status Show daemon status (default) | ||
| stop Stop the daemon process | ||
|
|
||
| The daemon syncs workspace state to iOS via WebSocket. It persists | ||
| across app restarts so terminal sessions survive upgrades. Use | ||
| 'cmux daemon stop' to kill it explicitly. | ||
| """ | ||
| case "ping": | ||
| return """ | ||
| Usage: cmux ping | ||
|
|
@@ -6721,6 +6958,17 @@ struct CMUXCLI { | |
|
|
||
| Show top-level CLI usage and command list. | ||
| """ | ||
| case "login": | ||
| return """ | ||
| Usage: cmux login [--no-open] | ||
|
|
||
| Sign in to your cmux account using browser-based authentication. | ||
| Opens a browser for sign-in, then polls until complete. | ||
| Tokens are stored in the system keychain. | ||
|
|
||
| Flags: | ||
| --no-open Print the sign-in URL instead of opening the browser. | ||
| """ | ||
| case "welcome": | ||
| return """ | ||
| Usage: cmux welcome | ||
|
|
@@ -14319,6 +14567,7 @@ struct CMUXCLI { | |
| --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. | ||
|
|
||
| Commands: | ||
| login [--no-open] | ||
| welcome | ||
| shortcuts | ||
| feedback [--email <email> --body <text> [--image <path> ...]] | ||
|
|
@@ -14333,6 +14582,7 @@ struct CMUXCLI { | |
| capabilities | ||
| rpc <method> [json-params] | ||
| identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] | ||
| daemon [status|stop] | ||
| list-windows | ||
| current-window | ||
| new-window | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Use an exact version check for
zig version; the current prefix grep can accept unintended versions.Prompt for AI agents