From 2f137db55429f83652908f49a6918e80bfb12043 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Sun, 24 May 2026 13:00:19 -0400 Subject: [PATCH 1/7] Update Cued agent example prompts --- .../CuedNative/AgentLaunchService.swift | 171 +++++++++++++++--- .../CuedNative/RootWindowController.swift | 8 +- .../Interface/Pages/ExamplesPage.swift | 15 +- .../Interface/Root/AgentExamples.swift | 24 +-- .../Sources/Interface/Root/RootView.swift | 5 +- .../Interface/Root/RootViewPreviews.swift | 2 +- .../Tests/CuedNativeTests/RootViewTests.swift | 70 +++++++ .../preview/support/PreviewSupport.swift | 2 +- 8 files changed, 246 insertions(+), 51 deletions(-) diff --git a/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift b/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift index be083fdf..be5ac8b6 100644 --- a/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift +++ b/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift @@ -14,7 +14,11 @@ enum AgentLaunchService { } @MainActor - static func launch(exampleID: AgentExampleID, target: AgentTarget) -> AgentLaunchFeedback { + static func launch( + exampleID: AgentExampleID, + target: AgentTarget, + userFocus: String? = nil + ) -> AgentLaunchFeedback { let status = status(for: target) guard status.readiness == .ready else { return AgentLaunchFeedback( @@ -25,7 +29,7 @@ enum AgentLaunchService { } let repoPath = resolveRepoPath() - let prompt = prompt(for: exampleID, repoPath: repoPath) + let prompt = prompt(for: exampleID, repoPath: repoPath, userFocus: userFocus) NSPasteboard.general.clearContents() NSPasteboard.general.setString(prompt, forType: .string) @@ -45,6 +49,39 @@ enum AgentLaunchService { } } + nonisolated static func focusInstruction(_ userFocus: String?) -> String { + let focus = (userFocus ?? "") + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + guard !focus.isEmpty else { + return """ + Pick the best candidate from local context. + + Choose exactly one high-signal candidate using metadata-first ranking: + recency, unread or open-loop signal, two-way message volume, attachments, + reactions, calls, and deterministic contact/source evidence. Keep the + search bounded: inspect at most 15 candidate conversations or contacts, + drill into one, and stop after one representative example. + """ + } + return """ + Use this user focus as the constraint: \(focus) + + Treat this focus as the required scope for all evidence selection and + synthesis. If local Cued data does not support it, say "unsupported by local + data", give the counts or metadata that prove it, and only then offer the + nearest supported fallback. Do not broaden silently. + """ + } + + nonisolated static func prompt( + for exampleID: AgentExampleID, + repoPath: String, + userFocus: String? + ) -> String { + agentLaunchPrompt(for: exampleID, repoPath: repoPath, userFocus: userFocus) + } + private nonisolated static func status(for target: AgentTarget) -> AgentTargetStatus { switch target { case .codexApp: @@ -379,11 +416,59 @@ enum AgentLaunchService { } -private func prompt(for exampleID: AgentExampleID, repoPath: String) -> String { +private func agentLaunchPrompt( + for exampleID: AgentExampleID, + repoPath: String, + userFocus: String? +) -> String { + let focusInstruction = AgentLaunchService.focusInstruction(userFocus) let header = """ You are operating in the Cued repo at \(repoPath). - Use Cued's local data through the cued CLI. Keep private message contents out of fixtures and broad summaries; synthesize only what the task needs. + Use Cued's local data through the `cued` CLI. Keep private message contents out + of fixtures, logs, commits, and broad summaries; synthesize only what the task + needs. Prefer concrete source types, ids, counts, timestamps, and commands run + over vague claims. Do not use `sqlite3`; use `cued sql "..."` for read-only + SQL. + + \(focusInstruction) + + Start read-only: + - `cued status` + - `cued doctor` + - `cued integrations status` + - `cued logs --tail 200` + - targeted `cued sql "..."` queries after inspecting the local schema + + Data readiness: + - If projected data is readable but live source sync or integration health is + degraded, continue read-only with a freshness caveat. Repair only when the + needed projected data is unavailable. + - If Cued is actively syncing or projecting but `cued status` and read-only SQL + still work, do not repair. Report the projection/search-index backlog and + continue with a freshness caveat. + - Do not treat SQLite busy warnings during active projection as a reason to reset + data. Never run `cued reset --source ` without explicit user + confirmation. + - Validate recency with both `max(messages.sent_at)` and + `max(conversations.last_message_at)`. If they disagree materially, report the + mismatch and avoid claiming full current coverage. + + SQL guardrails: + - Cued timestamps are Unix epoch milliseconds; use + `datetime(sent_at/1000,'unixepoch','localtime')`. + - Start with metadata: ids, timestamps, direction, counts, type, platform, + unread count, attachment count, reaction count, and source tables. + - Do not select `messages.content`, `conversations.last_message_preview`, + participant names, sender names, contact names, phone numbers, emails, or + handles unless the task cannot be answered from metadata. If content is + necessary, inspect at most 25 messages from one conversation/topic and return + only synthesis, never raw bodies. + - Before FTS, check + `select count(*) from messages_fts` and + `select count(*) from message_fts_index_queue`. If FTS is empty or queued, + fall back to bounded `messages.content like ...` queries and say search + indexing is not ready. `messages_fts.message_id` joins to `messages.id`. """ switch exampleID { @@ -393,63 +478,89 @@ private func prompt(for exampleID: AgentExampleID, repoPath: String) -> String { Goal: debug why one Cued source is not producing useful local context. - Start with status, doctor, recent logs, permissions, integration auth state, and recent sync jobs. Prefer targeted local repair commands such as `cued integrations refresh`, `cued sync run`, `cued sync resume`, or `cued reset --source ` only when the evidence points there. + Work in this order: + 1. Inspect status, doctor output, recent logs, permissions, integration auth state, and recent sync jobs. + 2. Identify the single weakest source and the evidence for why it is weak. + 3. Try targeted local repair commands such as `cued integrations refresh`, `cued sync run`, or `cued sync resume` only when the evidence points there. Recommend `cued reset --source ` only after explicit user confirmation. + 4. Re-run the smallest verification command that proves whether context improved. - End with what changed, what was verified, and whether code escalation is required. + End with: root cause, commands run, what changed, what was verified, and whether code escalation is required. """ case .catchUp: return """ \(header) - Goal: prove where local context changes the answer. + Goal: pull Cued context from threads with the relevant people into a working project spec. - Use Cued read-only context to pick one recent question, thread, or relationship where a generic model would miss important context. Compare: - - what an agent would say with no local history - - what changes after reading relevant Cued messages, contacts, calls, and source metadata - - what action becomes possible because of the context + This is the "Context for a Project" card. Treat the user focus as a mini request like "pull context from threads with X, Y, and Z people and add it to this spec." Use read-only Cued context to find the threads, people, decisions, constraints, and open questions that belong in the spec. If the focus names people, search threads involving those people plus the project/topic; if it names only a project, find the strongest related threads and participants. - Return a compact before/after analysis with citations to local source types, not private message dumps. End with the smallest product improvement that would make this context easier for future agents to use. If Cued itself is unhealthy, repair the local runtime first using the ladder above. + Return: + - the project/spec boundary you inferred + - the people and conversations consulted, cited by metadata + - decisions, constraints, facts, preferences, and open questions to add + - a drop-in spec section with only synthesized context + - what is uncertain, missing, stale, or unsupported by local data + + Do not dump private messages. Cite local evidence by source type, conversation id, message id, timestamp, participant role, counts, and confidence. + If projected data is readable, answer from it with any freshness caveat. Repair only when the needed projected data is unavailable. """ case .followUps: return """ \(header) - Goal: find a real workflow where Cued turns messages into useful work. + Goal: recover what the user decided about X. - Search recent local context for a concrete workflow: follow-up drafting, meeting prep, recruiting, sales/customer development, school/project coordination, or another repeated task visible in the data. Avoid generic summaries. + This is the "Recover Decisions" card. Treat the user focus as the X in "what did I decide about X?" Use read-only Cued context to find decisions, commitments, reversals, settled preferences, unresolved disagreements, and later confirmations related to that topic. Avoid generic summaries and do not produce a broad inbox digest. + + If a user focus is provided, treat it as the decision topic. If no focus is provided, pick one recent topic with clear decision evidence and enough replayable history. Define what counts as a decision before searching: explicit acceptance/rejection, selected option, committed next step, owner/date assignment, repeated later behavior, or a message that resolves prior ambiguity. Return: - - the workflow you found - - the local evidence that makes it real - - the exact output an agent should produce - - how to measure whether it created value + - the decision question you answered + - the recovered decision, current best answer, and confidence + - a short decision timeline with evidence ids, timestamps, source types, and participant roles + - contradictions, reversals, and stale assumptions + - what should be checked before acting on the decision - Keep this read-only unless a local Cued operational issue blocks the query. If Cued itself is unhealthy, repair the local runtime first using the ladder above. + Keep this read-only unless a local Cued operational issue blocks the query. If projected data is readable, answer from it with any freshness caveat. Repair only when the needed projected data is unavailable. """ case .prepPerson: return """ \(header) - Goal: turn repeated local context into accumulated understanding. + Goal: act like a chief of staff over message context. + + This is the "Chief of Staff" card. Use read-only Cued context to tell the user about their day and design Codex automations or hooks that activate when messages arrive. Examples include: morning brief inputs, follow-up queues, meeting prep context, messages that should wake an agent, and hooks that fire when specific people, topics, or urgency signals appear. - Pick a person, project, or recurring topic from local Cued context. Build a durable brief that would still be useful next week: stable facts, open loops, preferences, unresolved questions, recent changes, and what an agent should remember before acting. + If a user focus is provided, it overrides the most active thread overall. Pick only a day-planning, automation, or hook candidate directly related to the focus. If no focus is provided, choose one repeated workflow with clear evidence and low noise. - Explain which parts came from replayable history versus one-off recent messages. Do not dump private messages; synthesize. Keep the work local and read-only unless Cued needs operational repair first. + Return: + - the chief-of-staff job to be done, such as day brief or message hook + - trigger conditions or schedule, including message events that should activate it + - context the agent should gather before acting + - the exact output it should produce, such as a day brief, queue, or hook spec + - actions that require confirmation, and cases where the agent should stay silent + - how to measure value and false positives + + Do not send messages, create calendar events, mark work done, or mutate Cued. If projected data is readable, answer from it with any freshness caveat. Repair only when the needed projected data is unavailable. """ case .findWarmIntros: return """ \(header) - Goal: design explicit intent and triggers for an always-on Cued agent. + Goal: find intros and relationship context for person Y, especially someone the user just met. + + This is the "Intros / Relationship Context" card. Treat the user focus as the Y in "who should I intro Y to?" Use read-only Cued context to answer who this person should meet, what relationship context matters, and what to remember after meeting them. A filled user focus may name the person, company, topic, or desired intro area. - Use local context to identify where a background agent would create value and where it would be noisy. Pick one candidate workflow and define: - - the user intent it serves - - the trigger conditions that should wake the agent up - - the context it needs before acting - - the action it may take - - the cases where it should stay silent or ask first + If the focus names a person, first identify the likely contact or conversation by deterministic local evidence. Then search for relationship context and possible intro targets using local conversations, contact handles/sources, prior messages, shared topics, and interaction strength. If the identity is ambiguous, say so and do not guess. + + Return: + - the person/context you matched, with confidence and ambiguity notes + - candidate intro targets or relationship-context notes + - why each candidate fits, using metadata and synthesized evidence + - suggested intro angle or question to ask the user before making the intro + - people or paths to avoid because evidence is weak, stale, private, or noisy - Return a small trigger spec that could be implemented or evaluated. Keep the work read-only unless Cued needs operational repair first. + Do not send introductions or reveal private message bodies. If projected data is readable, answer from it with any freshness caveat. Repair only when the needed projected data is unavailable. """ } } diff --git a/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift b/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift index e7152660..77d20209 100644 --- a/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift +++ b/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift @@ -90,8 +90,8 @@ final class RootWindowController: NSWindowController { onPlatformPageActiveChanged: { [weak self] isActive in self?.setPlatformAuthInteractionActive(isActive) }, - onLaunchAgentExample: { [weak self] exampleID, target in - self?.launchAgentExample(exampleID: exampleID, target: target) + onLaunchAgentExample: { [weak self] exampleID, target, userFocus in + self?.launchAgentExample(exampleID: exampleID, target: target, userFocus: userFocus) }, onFinish: { [weak self] in self?.finishSetup() } ) @@ -491,7 +491,7 @@ final class RootWindowController: NSWindowController { } } - private func launchAgentExample(exampleID: AgentExampleID, target: AgentTarget) { + private func launchAgentExample(exampleID: AgentExampleID, target: AgentTarget, userFocus: String?) { viewModel.showAgentLaunchFeedback( AgentLaunchFeedback( title: "Preparing \(target.title)", @@ -517,7 +517,7 @@ final class RootWindowController: NSWindowController { return } - let feedback = AgentLaunchService.launch(exampleID: exampleID, target: target) + let feedback = AgentLaunchService.launch(exampleID: exampleID, target: target, userFocus: userFocus) viewModel.showAgentLaunchFeedback(feedback) refreshAgentTargets() } diff --git a/native/macos/CuedNative/Sources/Interface/Pages/ExamplesPage.swift b/native/macos/CuedNative/Sources/Interface/Pages/ExamplesPage.swift index ecb940f0..55a74b7b 100644 --- a/native/macos/CuedNative/Sources/Interface/Pages/ExamplesPage.swift +++ b/native/macos/CuedNative/Sources/Interface/Pages/ExamplesPage.swift @@ -16,6 +16,8 @@ extension RootView { agentLaunchFeedback(feedback) } + agentFocusField + LazyVGrid( columns: [ GridItem(.flexible(), spacing: 12), @@ -29,7 +31,7 @@ extension RootView { targetStatuses: viewModel.agentTargetStatuses, onLaunch: { target in viewModel.clearAgentLaunchFeedback() - onLaunchAgentExample(example.id, target) + onLaunchAgentExample(example.id, target, agentExampleFocus) } ) } @@ -38,6 +40,17 @@ extension RootView { } } + private var agentFocusField: some View { + VStack(alignment: .leading, spacing: 7) { + Text("What should Cued look into?") + .font(.subheadline.weight(.semibold)) + + TextField("Project + people, decision X, today, or person Y", text: $agentExampleFocus) + .textFieldStyle(.roundedBorder) + } + .padding(.top, 8) + } + func agentLaunchFeedback(_ feedback: AgentLaunchFeedback) -> some View { HStack(spacing: 10) { Image(systemName: feedback.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill") diff --git a/native/macos/CuedNative/Sources/Interface/Root/AgentExamples.swift b/native/macos/CuedNative/Sources/Interface/Root/AgentExamples.swift index bad2a26d..3d19d9af 100644 --- a/native/macos/CuedNative/Sources/Interface/Root/AgentExamples.swift +++ b/native/macos/CuedNative/Sources/Interface/Root/AgentExamples.swift @@ -109,30 +109,30 @@ public let agentExamples: [AgentExample] = [ AgentExample( id: .catchUp, icon: "point.3.connected.trianglepath.dotted", - title: "Context Use", - detail: "Find where local context changes what the agent should do.", - promptPreview: "Show where context changes the answer" + title: "Context for a Project", + detail: "Pull context from threads into a spec.", + promptPreview: "Use threads with specific people to fill in a spec" ), AgentExample( id: .followUps, icon: "rectangle.3.group.bubble.left", - title: "Workflow Value", - detail: "Turn recent messages into useful work, not a generic summary.", - promptPreview: "Find a real workflow Cued can improve" + title: "Recover Decisions", + detail: "Answer what you decided about something.", + promptPreview: "What did I decide about X?" ), AgentExample( id: .prepPerson, icon: "arrow.trianglehead.clockwise", - title: "Accumulated Understanding", - detail: "Build a durable brief from replayable local history.", - promptPreview: "Turn repeated context into understanding" + title: "Chief of Staff", + detail: "Brief your day and design message hooks.", + promptPreview: "Tell me about my day and what should activate" ), AgentExample( id: .findWarmIntros, icon: "scope", - title: "Intent And Triggers", - detail: "Decide when an always-on agent should act or stay quiet.", - promptPreview: "Design explicit triggers from local context" + title: "Intros / Relationship Context", + detail: "Find who a new person should meet.", + promptPreview: "Who should I intro Y to?" ), ] diff --git a/native/macos/CuedNative/Sources/Interface/Root/RootView.swift b/native/macos/CuedNative/Sources/Interface/Root/RootView.swift index e4870198..ce4148bb 100644 --- a/native/macos/CuedNative/Sources/Interface/Root/RootView.swift +++ b/native/macos/CuedNative/Sources/Interface/Root/RootView.swift @@ -25,7 +25,7 @@ public struct RootView: View { let onRemoveIntegration: (String, String) -> Void let onConnectIntegration: (String, String) -> Void let onPlatformPageActiveChanged: (Bool) -> Void - let onLaunchAgentExample: (AgentExampleID, AgentTarget) -> Void + let onLaunchAgentExample: (AgentExampleID, AgentTarget, String?) -> Void let onFinish: () -> Void @State var addAccountPrompt: AddAccountPrompt? @@ -34,6 +34,7 @@ public struct RootView: View { @State var pendingIntegrationActionIDs = Set() @State var pendingRemovedIntegrationIDs = Set() @State var activePermissionGuideKey: String? + @State var agentExampleFocus = "" public init( viewModel: RootViewModel, @@ -46,7 +47,7 @@ public struct RootView: View { onRemoveIntegration: @escaping (String, String) -> Void, onConnectIntegration: @escaping (String, String) -> Void, onPlatformPageActiveChanged: @escaping (Bool) -> Void, - onLaunchAgentExample: @escaping (AgentExampleID, AgentTarget) -> Void, + onLaunchAgentExample: @escaping (AgentExampleID, AgentTarget, String?) -> Void, onFinish: @escaping () -> Void ) { self.viewModel = viewModel diff --git a/native/macos/CuedNative/Sources/Interface/Root/RootViewPreviews.swift b/native/macos/CuedNative/Sources/Interface/Root/RootViewPreviews.swift index 5306d41d..d2475bd8 100644 --- a/native/macos/CuedNative/Sources/Interface/Root/RootViewPreviews.swift +++ b/native/macos/CuedNative/Sources/Interface/Root/RootViewPreviews.swift @@ -80,7 +80,7 @@ struct RootPreview: View { onRemoveIntegration: { _, _ in }, onConnectIntegration: { _, _ in }, onPlatformPageActiveChanged: { _ in }, - onLaunchAgentExample: { _, _ in }, + onLaunchAgentExample: { _, _, _ in }, onFinish: {} ) } diff --git a/native/macos/CuedNative/Tests/CuedNativeTests/RootViewTests.swift b/native/macos/CuedNative/Tests/CuedNativeTests/RootViewTests.swift index d7b43d2e..00daefc2 100644 --- a/native/macos/CuedNative/Tests/CuedNativeTests/RootViewTests.swift +++ b/native/macos/CuedNative/Tests/CuedNativeTests/RootViewTests.swift @@ -108,6 +108,76 @@ final class RootViewTests: XCTestCase { XCTAssertTrue(path.contains("\(NSHomeDirectory())/.npm-global/bin")) } + func testAgentLaunchFocusInstructionHandlesBlankAndFilledInput() { + let blankInstruction = AgentLaunchService.focusInstruction(nil) + XCTAssertTrue(blankInstruction.contains("Pick the best candidate from local context.")) + XCTAssertTrue(blankInstruction.contains("Choose exactly one high-signal candidate")) + XCTAssertTrue(blankInstruction.contains("most 15 candidate conversations or contacts")) + + let whitespaceInstruction = AgentLaunchService.focusInstruction(" \n") + XCTAssertEqual(whitespaceInstruction, blankInstruction) + + let filledInstruction = AgentLaunchService.focusInstruction(" follow-ups\nI owe\tthis week ") + XCTAssertTrue(filledInstruction.contains("Use this user focus as the constraint: follow-ups I owe this week")) + XCTAssertTrue(filledInstruction.contains("required scope")) + XCTAssertTrue(filledInstruction.contains("unsupported by local")) + XCTAssertTrue(filledInstruction.contains("Do not broaden silently.")) + } + + func testAgentLaunchPromptsCoverCurrentExamples() { + let expectedPhrases: [(AgentExampleID, [String])] = [ + ( + .catchUp, + [ + "Context for a Project", + "pull Cued context from threads with the relevant people into a working project spec", + "drop-in spec section", + ] + ), + ( + .followUps, + [ + "Recover Decisions", + "recover what the user decided about X", + "short decision timeline", + ] + ), + ( + .prepPerson, + [ + "Chief of Staff", + "tell the user about their day and design Codex automations or hooks", + "trigger conditions or schedule", + ] + ), + ( + .findWarmIntros, + [ + "Intros / Relationship Context", + "who should I intro Y to", + "suggested intro angle", + ] + ), + ] + + for (exampleID, phrases) in expectedPhrases { + let prompt = AgentLaunchService.prompt( + for: exampleID, + repoPath: "/tmp/cued", + userFocus: "Cued agent launch examples" + ) + + XCTAssertTrue(prompt.contains("Use Cued's local data through the `cued` CLI")) + XCTAssertTrue(prompt.contains("Do not use `sqlite3`")) + XCTAssertTrue(prompt.contains("continue read-only with a freshness caveat")) + XCTAssertTrue(prompt.contains("Use this user focus as the constraint: Cued agent launch examples")) + XCTAssertFalse(prompt.contains("fix only the local runtime issue first")) + for phrase in phrases { + XCTAssertTrue(prompt.contains(phrase), "Prompt for \(exampleID) is missing \(phrase)") + } + } + } + func testPermissionGuideURLsMatchExpectedSystemSettingsPanes() { XCTAssertEqual( permissionGuideURL(for: "contacts"), diff --git a/native/macos/CuedNative/tools/preview/support/PreviewSupport.swift b/native/macos/CuedNative/tools/preview/support/PreviewSupport.swift index 01df7fdd..c7533906 100644 --- a/native/macos/CuedNative/tools/preview/support/PreviewSupport.swift +++ b/native/macos/CuedNative/tools/preview/support/PreviewSupport.swift @@ -75,7 +75,7 @@ public enum Preview { onRemoveIntegration: { _, _ in }, onConnectIntegration: { _, _ in }, onPlatformPageActiveChanged: { _ in }, - onLaunchAgentExample: { _, _ in }, + onLaunchAgentExample: { _, _, _ in }, onFinish: {} ) .frame(width: previewCase.size.width, height: previewCase.size.height) From 226430317dde73fa17a10d9f251a3391eab319e4 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Sun, 24 May 2026 16:05:58 -0400 Subject: [PATCH 2/7] Centralize runtime configuration --- .env.example | 113 +----- .github/workflows/release-cued-macos.yml | 10 - README.md | 20 +- bin/cued-wrapper | 2 +- docs/configuration.md | 94 +++++ docs/integration-policy.md | 2 +- native/helpers/slack-go/main.go | 24 +- .../CuedNative/AgentLaunchService.swift | 5 +- .../Sources/CuedNative/AppRuntime.swift | 81 +--- .../CuedNative/RootWindowController.swift | 6 +- .../Sources/CuedNative/RuntimeSupport.swift | 104 ++++- .../Interface/Root/RootDisplayHelpers.swift | 4 +- .../CuedNativeTests/RuntimeSupportTests.swift | 90 ++++- package.json | 1 + pnpm-lock.yaml | 9 + scripts/bench-daemon-memory-macos.sh | 73 ++-- scripts/bench-gui-responsiveness.sh | 65 +++- scripts/build-cued-daemon-app.sh | 65 ++-- scripts/build-cued-release-artifacts.sh | 6 +- scripts/build-cued-release-metadata.mjs | 21 +- scripts/fetch-node-runtime-macos.sh | 2 +- scripts/fetch-playwright-chromium-macos.sh | 4 +- scripts/install-cued-release.sh | 51 ++- scripts/request-macos-access.sh | 35 +- scripts/smoke-auth-lifecycle.ts | 70 +++- scripts/smoke-local-clean-macos.sh | 21 +- scripts/validate-cued-release-artifact.sh | 4 +- src/cli-contacts-memory.test.ts | 18 +- src/cli-paths.test.ts | 21 +- src/cli.ts | 24 +- src/client.ts | 7 +- src/core/app-metadata.test.ts | 12 +- src/core/app-metadata.ts | 53 ++- src/core/config.test.ts | 54 +-- src/core/config.ts | 354 ++++++++++++++---- src/core/env-example.test.ts | 26 ++ src/core/env.test.ts | 44 +++ src/core/env.ts | 35 ++ src/core/logging.ts | 2 +- src/db/sqlite.ts | 1 + src/macos/install.test.ts | 9 +- src/macos/install.ts | 22 +- src/platforms/contacts/sync.test.ts | 47 +-- src/platforms/contacts/sync.ts | 13 +- src/platforms/core/auth/chromium-worker.ts | 21 +- src/platforms/core/auth/chromium.ts | 2 + src/platforms/core/auth/native.ts | 6 +- src/platforms/core/auth/oauth.ts | 12 +- src/platforms/core/auth/qr-native.ts | 17 +- src/platforms/core/invocation.test.ts | 50 ++- src/platforms/core/invocation.ts | 83 ++-- src/platforms/core/runner.test.ts | 12 +- src/platforms/core/runner.ts | 28 +- src/platforms/core/runtime-paths.ts | 4 +- .../core/state/integration-state.test.ts | 174 ++++----- src/platforms/core/state/local.ts | 19 +- .../slack-desktop-import-removal.test.ts | 35 +- src/platforms/discord/sync/bundle.test.ts | 21 +- src/platforms/discord/sync/bundle.ts | 51 +-- src/platforms/discord/sync/worker.ts | 6 +- src/platforms/gmail/oauth/client.test.ts | 15 +- src/platforms/gmail/oauth/client.ts | 25 +- src/platforms/gmail/sync/bundle.ts | 16 +- src/platforms/gmail/sync/worker.ts | 18 +- src/platforms/imessage/sync.test.ts | 59 ++- src/platforms/imessage/sync.ts | 34 +- src/platforms/imessage/worker.ts | 10 +- src/platforms/linkedin/sync/bundle.ts | 24 +- src/platforms/linkedin/sync/worker.ts | 13 +- src/platforms/signal/cli/client.test.ts | 20 +- src/platforms/signal/cli/client.ts | 31 +- src/platforms/signal/sync/bundle.ts | 5 +- src/platforms/signal/sync/worker.ts | 11 +- src/platforms/slack/auth/desktop-import.ts | 26 +- src/platforms/slack/e2e.test.ts | 41 +- src/platforms/slack/helper/binary.test.ts | 7 +- src/platforms/slack/helper/binary.ts | 30 +- src/platforms/slack/helper/client.ts | 6 +- src/platforms/slack/sync/bundle.ts | 14 +- src/platforms/slack/sync/worker.ts | 17 +- src/platforms/whatsapp/helper/pair.test.ts | 23 +- src/platforms/whatsapp/helper/pair.ts | 18 +- src/platforms/whatsapp/sync/worker.ts | 13 +- src/runtime/attachments.ts | 13 +- src/runtime/daemon/server.test.ts | 34 +- src/runtime/daemon/server.ts | 272 ++++---------- src/runtime/doctor-status.test.ts | 33 +- src/runtime/doctor.ts | 4 +- src/runtime/native-binary.test.ts | 60 +-- src/runtime/native-binary.ts | 71 ++-- src/runtime/onboarding.test.ts | 68 ++-- src/runtime/perf/run.ts | 1 - src/runtime/projection/worker.test.ts | 15 +- src/runtime/projection/worker.ts | 34 +- src/runtime/updater/service.test.ts | 26 +- src/runtime/updater/service.ts | 10 +- src/skills/install.test.ts | 13 +- src/telemetry/client.test.ts | 7 +- src/telemetry/client.ts | 9 +- src/telemetry/events.ts | 4 +- 100 files changed, 1918 insertions(+), 1497 deletions(-) create mode 100644 docs/configuration.md create mode 100644 src/core/env-example.test.ts create mode 100644 src/core/env.test.ts create mode 100644 src/core/env.ts diff --git a/.env.example b/.env.example index 8f055a8e..a859685e 100644 --- a/.env.example +++ b/.env.example @@ -1,109 +1,18 @@ -# Local-only Cued environment overrides. -# Nothing here is required for a normal install. Prefer the app defaults unless -# you are developing, packaging, benchmarking, or isolating test state. +# Cued loads repo-local .env files through dotenv at Node process startup. +# Keep this file limited to secrets and credential file paths. Non-secret +# runtime tuning belongs in src/core/config.ts. -# Runtime metadata -CUED_RELEASE_CHANNEL=internal -CUED_APP_VERSION= - -# Local state and secrets -CUED_HOME= -CUED_DB_PATH= +# Local database key override. +# Normal installs use the macOS Keychain instead. CUED_DB_KEY= + +# Gmail OAuth client JSON paths. +# The JSON contains a client_secret; official builds bundle this separately. CUED_GOOGLE_OAUTH_CLIENT_FILE= GOOGLE_OAUTH_CLIENT_FILE= - -# Native helper and platform path overrides -CUED_APP_PATH= -CUED_AUTH_NATIVE_BINARY= -CUED_IMESSAGE_DB_PATH= -CUED_IMESSAGE_NATIVE_BINARY= -CUED_CALL_HISTORY_DB_PATH= -CUED_CONTACTS_NATIVE_BINARY= -CUED_CONTACTS_JSON_PATH= -CUED_SIGNAL_CLI_PATH= -CUED_SIGNAL_ACCOUNT= -CUED_WHATSAPP_HELPER_BINARY= -CUED_WHATSAPP_DESKTOP_SOURCE_PATH= -CUED_SLACK_APP_BINARY= -CUED_SLACK_HELPER_BINARY= -CUED_SLACK_USER_DATA_DIR= -CUED_SLACK_REMOTE_DEBUGGING_PORT= -CUED_CHROMIUM_EXECUTABLE_PATH= - -# Daemon scheduling and projection tuning -CUED_AUTOSYNC_PLATFORMS= -CUED_REALTIME_PLATFORMS= -CUED_AUTOSYNC_INTERVAL_MS= -CUED_AUTOSYNC_INTERVAL_SIGNAL_MS= -CUED_AUTOSYNC_INTERVAL_WHATSAPP_MS= -CUED_AUTOSYNC_INTERVAL_DISCORD_MS= -CUED_AUTOSYNC_SCHEDULER_TICK_MS= -CUED_AUTOSYNC_PROJECTION_BACKLOG_PAUSE_EVENTS= -CUED_INGEST_CONCURRENCY= -CUED_PROJECTION_BATCH_SIZE= -CUED_MESSAGE_FTS_INDEX_BATCH_SIZE= -CUED_SYNC_CONTINUE_DELAY_MS= -CUED_PROJECTION_CONTINUE_DELAY_MS= -CUED_CONTINUATION_PROJECTION_INTERVAL_MS= -CUED_CONTINUATION_PROJECTION_BACKLOG_EVENTS= -CUED_DEFERRED_PROJECTION_COALESCE_MS= -CUED_REALTIME_PROJECTION_ENABLED= -CUED_REALTIME_PROJECTION_BATCH_SIZE= -CUED_SIGNAL_RECONNECT_SYNC_COOLDOWN_MS= -CUED_WHATSAPP_RESYNC_PAGE_BUDGET= - -# Platform sync limits and realtime toggles -CUED_GMAIL_PAGE_SIZE= -CUED_GMAIL_PAGE_BUDGET= -CUED_GMAIL_FETCH_CONCURRENCY= -CUED_LINKEDIN_CONNECTION_PAGES= -CUED_LINKEDIN_CONVERSATION_PAGES= -CUED_LINKEDIN_MESSAGE_PAGES= -CUED_LINKEDIN_FETCH_CONCURRENCY= -CUED_DISCORD_REALTIME_ENABLED= -CUED_DISCORD_DM_POLL_MS= -CUED_DISCORD_SYNC_MESSAGE_CHANNEL_LIMIT= -CUED_DISCORD_SYNC_MESSAGES_PER_CHANNEL_LIMIT= -CUED_DISCORD_SYNC_BACKFILL_PAGE_LIMIT= -CUED_SLACK_REALTIME_ENABLED= -CUED_SLACK_REALTIME_POLL_MS= -CUED_SLACK_REALTIME_USER_REFRESH_MS= -CUED_SLACK_REALTIME_CONVERSATION_LIMIT= -CUED_SLACK_REALTIME_MESSAGE_LIMIT= -CUED_SLACK_API_PAGE_BUDGET= -CUED_SLACK_DESKTOP_IMPORT_TIMEOUT_MS= - -# Local runtime limits -CUED_DAEMON_REQUEST_TIMEOUT_MS= -CUED_CHROMIUM_AUTH_TIMEOUT_MS= -CUED_OAUTH_TIMEOUT_MS= -CUED_ATTACHMENT_DISK_RESERVE_BYTES= -CUED_LOG_MAX_BYTES= - -# Worker, cursor, and fixture overrides -CUED_ACCOUNT_KEY= -CUED_IMESSAGE_LAST_ROWID= -CUED_SIGNAL_LAST_SYNC_AT= -CUED_LINKEDIN_LAST_SYNC_AT= -CUED_LINKEDIN_SYNC_TOKEN= -CUED_DISCORD_SOURCE_CURSOR= -CUED_DISCORD_SYNC_PROOFS= -CUED_PROJECTION_WORKER_RUN= -CUED_WHATSAPP_SYNC_SOURCE= -CUED_FAKE_QR_AUTH_RESULT= -CUED_FAKE_CHROMIUM_AUTH_RESULT= - -# Packaging and release -CUED_NODE_PATH= -CUED_DB_PATH_OVERRIDE= CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE= + +# Release signing credential handles. +# The underlying certificates and notary credentials live outside the repo. CUED_CODESIGN_IDENTITY= CUED_NOTARY_PROFILE= -CUED_RELEASE_REPO=Cue-d/cued -CUED_RELEASE_API_BASE=https://api.github.com -CUED_RELEASE_VERSION= -CUED_RELEASE_TAG= -CUED_RELEASE_PUBLISHED_AT= -CUED_DESTINATION= -CUED_OPEN_APP=1 diff --git a/.github/workflows/release-cued-macos.yml b/.github/workflows/release-cued-macos.yml index 2883d64e..b7359cd2 100644 --- a/.github/workflows/release-cued-macos.yml +++ b/.github/workflows/release-cued-macos.yml @@ -43,12 +43,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Resolve release version - run: | - VERSION="$(node -p "require('./package.json').version")" - echo "CUED_RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" - echo "CUED_RELEASE_TAG=v$VERSION" >> "$GITHUB_ENV" - - name: Prepare bundled Google OAuth client run: | if [[ -z "${CUED_GOOGLE_OAUTH_CLIENT_JSON:-}" ]]; then @@ -76,13 +70,9 @@ jobs: env: CUED_CODESIGN_IDENTITY: ${{ secrets.CUED_CODESIGN_IDENTITY }} CUED_NOTARY_PROFILE: ${{ secrets.CUED_NOTARY_PROFILE }} - CUED_RELEASE_CHANNEL: stable - name: Build release metadata run: node scripts/build-cued-release-metadata.mjs - env: - CUED_RELEASE_CHANNEL: stable - CUED_RELEASE_REPO: Cue-d/cued - name: Create or update release run: | diff --git a/README.md b/README.md index d96ddf72..b08f5d73 100644 --- a/README.md +++ b/README.md @@ -209,16 +209,20 @@ Release packaging can inject the official client without committing it: CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE=/private/path/google-oauth-client.json pnpm build:app:macos ``` -### Idle performance controls +### Configuration -The daemon defaults to a low-idle scheduler tick and platform-specific sync intervals. Tune these only when debugging or benchmarking: +Cued loads repo-root `.env` files through dotenv at Node process startup. +`.env.example` is a short credential reference, not an inventory of every +internal `CUED_*` value. Normal installs use app defaults, `~/.cued`, Keychain, +and bundled helper discovery. -```bash -CUED_AUTOSYNC_SCHEDULER_TICK_MS=15000 -CUED_AUTOSYNC_INTERVAL_MS=60000 -CUED_AUTOSYNC_INTERVAL_SIGNAL_MS=300000 -CUED_AUTOSYNC_INTERVAL_WHATSAPP_MS=300000 -``` +For packaged app launch overrides, the macOS host reads `~/.cued/daemon.env`. +Use that only for rare credential-path overrides. Non-secret defaults and +runtime path overrides live in `src/core/config.ts`; isolated local runs can pass +`--config path/to/config.json`. + +See [docs/configuration.md](docs/configuration.md) for the configuration ownership +rules. ### Contributor workflow diff --git a/bin/cued-wrapper b/bin/cued-wrapper index ebb7798b..b5413ef1 100755 --- a/bin/cued-wrapper +++ b/bin/cued-wrapper @@ -1,3 +1,3 @@ #!/bin/sh SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -exec "${CUED_NODE_PATH:-node}" "$SCRIPT_DIR/../dist/cli.js" "$@" +exec node "$SCRIPT_DIR/../dist/cli.js" "$@" diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..c5bd62f2 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,94 @@ +# Configuration + +Cued should run without local environment setup. A normal install gets its paths +from the app bundle, stores data under `~/.cued`, stores integration secrets in +Keychain, and uses source defaults for daemon behavior. + +## What `.env.example` Is + +Node entrypoints load repo-root `.env` files through dotenv before reading Cued +configuration. `.env.example` is still a credential reference, not a general app +configuration registry. + +Keep `.env.example` limited to values that are secret-bearing or point at +secret-bearing credential files: + +- `CUED_DB_KEY`, only for bypassing the normal Keychain database key path. +- Gmail OAuth client JSON paths, because those files contain `client_secret`. +- release signing/notary credential handles used by packaging. + +Do not add daemon scheduling knobs, worker cursors, fixture flags, helper binary +paths, release metadata, or benchmark controls to `.env.example`. + +## Runtime Configuration Owners + +The runtime has a few existing configuration owners. Reuse these before adding a +new environment variable. + +| Need | Owner | +| --- | --- | +| Normal app paths | `src/core/config.ts` plus the app bundle launcher | +| Local data directory | `src/core/config.ts`; isolated runs pass `--config path/to/config.json` | +| Database encryption | Keychain by default, `CUED_DB_KEY` only as an override | +| Daemon scheduling and projection defaults | `src/core/config.ts` | +| Platform sync limits | `src/core/config.ts` | +| Integration credentials and tokens | Keychain | +| User hooks | `~/.cued/hooks.toml` | +| Packaged app runtime path overrides | `~/.cued/config.json` | +| Packaged app credential env overrides | `~/.cued/daemon.env` | +| Worker cursors and sync proofs | structured stdin invocation from the parent daemon | +| Build, release, smoke, and benchmark settings | the script that consumes them | + +## Runtime `config.json` + +The default runtime data directory is `~/.cued`. For isolated local runs, write +a small JSON config and pass it as a CLI argument: + +```json +{ + "home": "/tmp/cued-home", + "dbPath": "/tmp/cued-home/local.db" +} +``` + +```bash +pnpm exec tsx src/cli.ts --config /tmp/cued-home/config.json status +``` + +The macOS host also reads this config shape and forwards the same config path +when it launches the bundled daemon. + +## `~/.cued/daemon.env` + +The macOS host reads `~/.cued/daemon.env` before launching the daemon. This file +is the packaged app override path for rare secret-bearing values that must enter +the daemon process environment. It accepts `KEY=value` lines for the explicit +credential allowlist in `RuntimeSupport.swift`. + +Use it sparingly. It is appropriate for a local credential path or temporary +launch override that cannot live in the database or Keychain. It is not a +replacement for a checked-in defaults file. + +Example: + +```sh +CUED_GOOGLE_OAUTH_CLIENT_FILE=/Users/me/.cued/google-oauth-client.json +``` + +## Adding New Configuration + +Before adding a new env var, answer these in order: + +1. Can the value just be a source default in the current owner? +2. If it is a user-facing preference, should it be an app setting or CLI command + instead of a launch-time env var? +3. If it is a secret, can it live in Keychain rather than env? +4. If it is worker IPC, can the parent daemon inject it without documenting it + as user configuration? +5. If it is script-only, can it be documented next to that script instead of in + `.env.example`? + +For local development, prefer editing `src/core/config.ts`, using `--config` for +isolated runtime paths, or using a targeted script invocation. Environment +variables are still useful for secrets and credential file paths, but they +should not become the public configuration surface for every tweakable constant. diff --git a/docs/integration-policy.md b/docs/integration-policy.md index b0cf3698..c426ca38 100644 --- a/docs/integration-policy.md +++ b/docs/integration-policy.md @@ -46,6 +46,6 @@ The future acceptable shape is: - QR or phone login after app credentials are available - Keychain/local storage for sessions - documented rate-limit and auth-invalidation behavior -- clean `CUED_HOME` smoke coverage before public enablement +- clean config-file smoke coverage before public enablement Do not expose Telegram in onboarding, README, or the public capability matrix until that path works. diff --git a/native/helpers/slack-go/main.go b/native/helpers/slack-go/main.go index 6c172c55..6b8b5fd0 100644 --- a/native/helpers/slack-go/main.go +++ b/native/helpers/slack-go/main.go @@ -599,18 +599,36 @@ func commandErrorEnvelope(err error) commandEnvelope { func main() { if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: cued-slack-helper ") + fmt.Fprintln(os.Stderr, "usage: cued-slack-helper [--api-url URL] ") os.Exit(1) } - command := os.Args[1] + args := os.Args[1:] + apiURL := "" + if strings.HasPrefix(args[0], "--api-url=") { + apiURL = strings.TrimSpace(strings.TrimPrefix(args[0], "--api-url=")) + args = args[1:] + } else if args[0] == "--api-url" { + if len(args) < 3 { + fmt.Fprintln(os.Stderr, "--api-url requires a value and command") + os.Exit(1) + } + apiURL = strings.TrimSpace(args[1]) + args = args[2:] + } + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "missing helper command") + os.Exit(1) + } + + command := args[0] baseCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() ctx, cancel := context.WithTimeout(baseCtx, requestTimeout) defer cancel() runner := newHelperRunner(runnerOptions{ - apiURL: os.Getenv("CUED_SLACK_HELPER_API_URL"), + apiURL: apiURL, }) if command == "session" { diff --git a/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift b/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift index be5ac8b6..8eb27be3 100644 --- a/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift +++ b/native/macos/CuedNative/Sources/CuedNative/AgentLaunchService.swift @@ -289,12 +289,13 @@ enum AgentLaunchService { } private nonisolated static func resolveRepoPath() -> String { - let environment = ProcessInfo.processInfo.environment - if let configured = trimmedEnvironmentPath(environment, name: "CUED_REPO_PATH"), + let config = loadCuedRuntimeConfig() + if let configured = trimmedPath(config.repoPath), isCuedRepo(configured) { return configured } + let environment = ProcessInfo.processInfo.environment let home = NSHomeDirectory() var candidates = [ FileManager.default.currentDirectoryPath, diff --git a/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift b/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift index 03cf3003..4cf58c9d 100644 --- a/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift +++ b/native/macos/CuedNative/Sources/CuedNative/AppRuntime.swift @@ -14,18 +14,6 @@ let appMessagesDBPath = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Messages/chat.db").path -private func environmentPath(_ name: String) -> String? { - trimmedEnvironmentPath(ProcessInfo.processInfo.environment, name: name) -} - -private func configuredCuedHomePath() -> String { - configuredCuedHomePath(environment: ProcessInfo.processInfo.environment) -} - -private func configuredCuedDBPath() -> String { - configuredCuedDBPath(environment: ProcessInfo.processInfo.environment) -} - private func configuredDaemonLockPath() -> String { "\(configuredCuedHomePath())/daemon.lock" } @@ -38,18 +26,6 @@ private func configuredMenuBarStatusPath() -> String { "\(configuredCuedHomePath())/menu-bar-status.json" } -private func bundledHelperPath() -> String? { - guard let resourcePath = Bundle.main.resourcePath?.trimmingCharacters(in: .whitespacesAndNewlines), - !resourcePath.isEmpty else { - return nil - } - - let candidate = URL(fileURLWithPath: resourcePath) - .appendingPathComponent("helpers/cued-native-helper") - .path - return FileManager.default.fileExists(atPath: candidate) ? candidate : nil -} - private func currentTimeMs() -> Int { Int(Date().timeIntervalSince1970 * 1000) } @@ -1323,10 +1299,11 @@ final class DaemonSupervisor { } nonisolated func runCLI(arguments: [String]) -> (status: Int32, stdout: String, stderr: String)? { + let fullArguments = runtimeConfigArguments() + arguments if let daemonLaunchPath, !daemonLaunchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let process = Process() process.executableURL = URL(fileURLWithPath: daemonLaunchPath) - process.arguments = arguments + process.arguments = fullArguments process.environment = daemonEnvironment() let stdoutPipe = Pipe() let stderrPipe = Pipe() @@ -1343,15 +1320,16 @@ final class DaemonSupervisor { } } - let command = arguments.map(shellEscape).joined(separator: " ") + let command = fullArguments.map(shellEscape).joined(separator: " ") return runShellCommandAndCapture(command, environment: daemonEnvironment()) } nonisolated func launchCLI(arguments: [String]) -> Bool { + let fullArguments = runtimeConfigArguments() + arguments if let daemonLaunchPath, !daemonLaunchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let process = Process() process.executableURL = URL(fileURLWithPath: daemonLaunchPath) - process.arguments = arguments + process.arguments = fullArguments process.environment = daemonEnvironment() process.standardOutput = nil process.standardError = nil @@ -1363,52 +1341,30 @@ final class DaemonSupervisor { } } - let command = arguments.map(shellEscape).joined(separator: " ") + let command = fullArguments.map(shellEscape).joined(separator: " ") return launchShellCommand(command, environment: daemonEnvironment()) != nil } private nonisolated func daemonEnvironment() -> [String: String] { var environment = ProcessInfo.processInfo.environment - for (key, value) in loadConfiguredDaemonEnvironment(environment: environment) { + for (key, value) in loadConfiguredDaemonEnvironment() { environment[key] = value } - let executablePath = Bundle.main.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) - - if let helperPath = bundledHelperPath() { - if environment["CUED_NATIVE_BINARY"] == nil { - environment["CUED_NATIVE_BINARY"] = helperPath - } - if environment["CUED_IMESSAGE_NATIVE_BINARY"] == nil { - environment["CUED_IMESSAGE_NATIVE_BINARY"] = helperPath - } - if environment["CUED_CONTACTS_NATIVE_BINARY"] == nil { - environment["CUED_CONTACTS_NATIVE_BINARY"] = helperPath - } - } else if let executablePath, !executablePath.isEmpty { - if environment["CUED_IMESSAGE_NATIVE_BINARY"] == nil { - environment["CUED_IMESSAGE_NATIVE_BINARY"] = executablePath - } - if environment["CUED_CONTACTS_NATIVE_BINARY"] == nil { - environment["CUED_CONTACTS_NATIVE_BINARY"] = executablePath - } - } - if let executablePath, !executablePath.isEmpty, environment["CUED_AUTH_NATIVE_BINARY"] == nil - { - environment["CUED_AUTH_NATIVE_BINARY"] = executablePath - } - let bundlePath = Bundle.main.bundlePath.trimmingCharacters(in: .whitespacesAndNewlines) - if !bundlePath.isEmpty, environment["CUED_APP_PATH"] == nil { - environment["CUED_APP_PATH"] = bundlePath - } - return environment } + private nonisolated func runtimeConfigArguments() -> [String] { + let path = configuredCuedConfigPath() + return hasExplicitCuedConfigArgument() || FileManager.default.fileExists(atPath: path) + ? ["--config", path] + : [] + } + private func launchDaemonProcess() -> Process? { if let daemonLaunchPath, !daemonLaunchPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { let process = Process() process.executableURL = URL(fileURLWithPath: daemonLaunchPath) - process.arguments = ["daemon"] + process.arguments = runtimeConfigArguments() + ["daemon"] process.environment = daemonEnvironment() process.standardOutput = nil process.standardError = nil @@ -1968,11 +1924,8 @@ final class MenuBarAppController: NSObject, NSApplicationDelegate { @MainActor func runMenuBarApp() throws { let daemonLaunchPath = Bundle.main.path(forResource: "cued-cli", ofType: nil) - let daemonCommand = (Bundle.main.object(forInfoDictionaryKey: "CuedDaemonCommand") as? String) - ?? ProcessInfo.processInfo.environment["CUED_DAEMON_COMMAND"] - ?? "" - let dbPath = environmentPath("CUED_DB_PATH") - ?? (Bundle.main.object(forInfoDictionaryKey: "CuedDBPath") as? String) + let daemonCommand = (Bundle.main.object(forInfoDictionaryKey: "CuedDaemonCommand") as? String) ?? "" + let dbPath = (Bundle.main.object(forInfoDictionaryKey: "CuedDBPath") as? String) ?? configuredCuedDBPath() let bundlePath = Bundle.main.bundlePath.trimmingCharacters(in: .whitespacesAndNewlines) if !bundlePath.isEmpty { diff --git a/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift b/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift index 77d20209..c7de8008 100644 --- a/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift +++ b/native/macos/CuedNative/Sources/CuedNative/RootWindowController.swift @@ -242,7 +242,11 @@ final class RootWindowController: NSWindowController { } private func consumePrerequisiteSetupIntent() -> Bool { - guard !didEnsurePrerequisites, shouldAutoConfigurePrerequisites() else { + guard !didEnsurePrerequisites, + shouldAutoConfigurePrerequisites( + skipAutoPrerequisites: loadCuedRuntimeConfig().skipAutoPrerequisites == true + ) + else { return false } didEnsurePrerequisites = true diff --git a/native/macos/CuedNative/Sources/CuedNative/RuntimeSupport.swift b/native/macos/CuedNative/Sources/CuedNative/RuntimeSupport.swift index 63ebeae2..a9626a50 100644 --- a/native/macos/CuedNative/Sources/CuedNative/RuntimeSupport.swift +++ b/native/macos/CuedNative/Sources/CuedNative/RuntimeSupport.swift @@ -2,45 +2,119 @@ import Foundation private let permissionRelaunchSetupIntentFilename = "permission-relaunch-setup.intent" private let daemonEnvironmentFilename = "daemon.env" +private let runtimeConfigFilename = "config.json" +private let daemonEnvironmentAllowlist = Set([ + "CUED_DB_KEY", + "CUED_GOOGLE_OAUTH_CLIENT_FILE", + "CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE", +]) + +struct CuedRuntimeConfig: Decodable { + let home: String? + let dbPath: String? + let repoPath: String? + let skipAutoPrerequisites: Bool? +} + +private struct RuntimeConfigPathSelection { + let path: String + let explicit: Bool +} func trimmedEnvironmentPath(_ environment: [String: String], name: String) -> String? { - let value = environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines) - return value?.isEmpty == false ? value : nil + trimmedPath(environment[name]) +} + +func trimmedPath(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil +} + +func hasExplicitCuedConfigArgument(arguments: [String] = CommandLine.arguments) -> Bool { + arguments.contains("--config") || arguments.contains("--cued-config") + || arguments.contains { $0.hasPrefix("--config=") || $0.hasPrefix("--cued-config=") } +} + +private func runtimeConfigPathSelection( + arguments: [String] = CommandLine.arguments, + homeDirectory: String = NSHomeDirectory() +) -> RuntimeConfigPathSelection { + for (index, argument) in arguments.enumerated() { + if argument == "--config" || argument == "--cued-config" { + if index + 1 < arguments.count, let value = trimmedPath(arguments[index + 1]) { + return RuntimeConfigPathSelection(path: value, explicit: true) + } + } + for name in ["--config=", "--cued-config="] { + if argument.hasPrefix(name), let value = trimmedPath(String(argument.dropFirst(name.count))) { + return RuntimeConfigPathSelection(path: value, explicit: true) + } + } + } + return RuntimeConfigPathSelection(path: "\(homeDirectory)/.cued/\(runtimeConfigFilename)", explicit: false) +} + +func configuredCuedConfigPath( + arguments: [String] = CommandLine.arguments, + homeDirectory: String = NSHomeDirectory() +) -> String { + runtimeConfigPathSelection(arguments: arguments, homeDirectory: homeDirectory).path +} + +func loadCuedRuntimeConfig( + arguments: [String] = CommandLine.arguments, + homeDirectory: String = NSHomeDirectory() +) -> CuedRuntimeConfig { + let selection = runtimeConfigPathSelection(arguments: arguments, homeDirectory: homeDirectory) + let path = selection.path + guard FileManager.default.fileExists(atPath: path), + let data = FileManager.default.contents(atPath: path), + let config = try? JSONDecoder().decode(CuedRuntimeConfig.self, from: data) + else { + if selection.explicit { + let home = URL(fileURLWithPath: path).deletingLastPathComponent().path + return CuedRuntimeConfig(home: home, dbPath: nil, repoPath: nil, skipAutoPrerequisites: nil) + } + return CuedRuntimeConfig(home: nil, dbPath: nil, repoPath: nil, skipAutoPrerequisites: nil) + } + return config } func configuredCuedHomePath( - environment: [String: String], + arguments: [String] = CommandLine.arguments, homeDirectory: String = NSHomeDirectory() ) -> String { - if let cuedHome = trimmedEnvironmentPath(environment, name: "CUED_HOME") { + let config = loadCuedRuntimeConfig(arguments: arguments, homeDirectory: homeDirectory) + if let cuedHome = trimmedPath(config.home) { return cuedHome } - if let dbPath = trimmedEnvironmentPath(environment, name: "CUED_DB_PATH") { + if let dbPath = trimmedPath(config.dbPath) { return URL(fileURLWithPath: dbPath).deletingLastPathComponent().path } return "\(homeDirectory)/.cued" } func configuredCuedDBPath( - environment: [String: String], + arguments: [String] = CommandLine.arguments, homeDirectory: String = NSHomeDirectory() ) -> String { - trimmedEnvironmentPath(environment, name: "CUED_DB_PATH") - ?? "\(configuredCuedHomePath(environment: environment, homeDirectory: homeDirectory))/local.db" + let config = loadCuedRuntimeConfig(arguments: arguments, homeDirectory: homeDirectory) + return trimmedPath(config.dbPath) + ?? "\(configuredCuedHomePath(arguments: arguments, homeDirectory: homeDirectory))/local.db" } func configuredDaemonEnvironmentPath( - environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = CommandLine.arguments, homeDirectory: String = NSHomeDirectory() ) -> String { - "\(configuredCuedHomePath(environment: environment, homeDirectory: homeDirectory))/\(daemonEnvironmentFilename)" + "\(configuredCuedHomePath(arguments: arguments, homeDirectory: homeDirectory))/\(daemonEnvironmentFilename)" } func loadConfiguredDaemonEnvironment( - environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = CommandLine.arguments, homeDirectory: String = NSHomeDirectory() ) -> [String: String] { - let path = configuredDaemonEnvironmentPath(environment: environment, homeDirectory: homeDirectory) + let path = configuredDaemonEnvironmentPath(arguments: arguments, homeDirectory: homeDirectory) guard let contents = try? String(contentsOfFile: path, encoding: .utf8) else { return [:] } @@ -57,7 +131,7 @@ func loadConfiguredDaemonEnvironment( } let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines) - if key.hasPrefix("CUED_"), key.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) { + if daemonEnvironmentAllowlist.contains(key) { values[key] = value } } @@ -65,10 +139,10 @@ func loadConfiguredDaemonEnvironment( } func permissionRelaunchSetupIntentPath( - environment: [String: String] = ProcessInfo.processInfo.environment, + arguments: [String] = CommandLine.arguments, homeDirectory: String = NSHomeDirectory() ) -> String { - "\(configuredCuedHomePath(environment: environment, homeDirectory: homeDirectory))/\(permissionRelaunchSetupIntentFilename)" + "\(configuredCuedHomePath(arguments: arguments, homeDirectory: homeDirectory))/\(permissionRelaunchSetupIntentFilename)" } func markPermissionRelaunchSetupIntent() { diff --git a/native/macos/CuedNative/Sources/Interface/Root/RootDisplayHelpers.swift b/native/macos/CuedNative/Sources/Interface/Root/RootDisplayHelpers.swift index 17864672..49266095 100644 --- a/native/macos/CuedNative/Sources/Interface/Root/RootDisplayHelpers.swift +++ b/native/macos/CuedNative/Sources/Interface/Root/RootDisplayHelpers.swift @@ -244,11 +244,11 @@ func platformDescription(for configuration: PlatformConfig) -> String { } } -public func shouldAutoConfigurePrerequisites() -> Bool { +public func shouldAutoConfigurePrerequisites(skipAutoPrerequisites: Bool = false) -> Bool { if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { return false } - if ProcessInfo.processInfo.environment["CUED_SKIP_AUTO_PREREQUISITES"] == "1" { + if skipAutoPrerequisites { return false } let bundlePath = Bundle.main.bundlePath.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift b/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift index 4e65d34c..fe279d19 100644 --- a/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift +++ b/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift @@ -2,51 +2,119 @@ import XCTest @testable import CuedNative final class RuntimeSupportTests: XCTestCase { - func testConfiguredCuedHomePathPrefersExplicitHome() { + private var tempURLs: [URL] = [] + + override func tearDown() { + for url in tempURLs { + try? FileManager.default.removeItem(at: url) + } + tempURLs.removeAll() + super.tearDown() + } + + private func writeConfig(_ json: String) throws -> String { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cued-runtime-config-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + tempURLs.append(directory) + let path = directory.appendingPathComponent("config.json") + try json.write(to: path, atomically: true, encoding: .utf8) + return path.path + } + + func testConfiguredCuedHomePathPrefersExplicitHome() throws { + let configPath = try writeConfig(#"{"home":" /tmp/cued-home ","dbPath":"/tmp/ignored/local.db"}"#) let homePath = configuredCuedHomePath( - environment: [ - "CUED_HOME": " /tmp/cued-home ", - "CUED_DB_PATH": "/tmp/ignored/local.db", - ], + arguments: ["Cued", "--config", configPath], homeDirectory: "/Users/test" ) XCTAssertEqual(homePath, "/tmp/cued-home") } - func testConfiguredCuedDBPathFallsBackFromDBPathOrHomeDirectory() { + func testConfiguredCuedDBPathFallsBackFromDBPathOrHomeDirectory() throws { + let configPath = try writeConfig(#"{"dbPath":" /tmp/cued/local.db "}"#) XCTAssertEqual( configuredCuedDBPath( - environment: ["CUED_DB_PATH": " /tmp/cued/local.db "], + arguments: ["Cued", "--config", configPath], homeDirectory: "/Users/test" ), "/tmp/cued/local.db" ) XCTAssertEqual( - configuredCuedDBPath(environment: [:], homeDirectory: "/Users/test"), + configuredCuedDBPath(arguments: ["Cued"], homeDirectory: "/Users/test"), "/Users/test/.cued/local.db" ) } - func testPermissionRelaunchSetupIntentPathUsesConfiguredCuedHome() { + func testExplicitMissingConfigDoesNotFallBackToRealHome() throws { + let missingPath = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-cued-config-\(UUID().uuidString)") + .appendingPathComponent("config.json") + .path + + XCTAssertEqual( + configuredCuedHomePath( + arguments: ["Cued", "--config", missingPath], + homeDirectory: "/Users/test" + ), + URL(fileURLWithPath: missingPath).deletingLastPathComponent().path + ) + XCTAssertEqual( + configuredCuedDBPath( + arguments: ["Cued", "--config", missingPath], + homeDirectory: "/Users/test" + ), + "\(URL(fileURLWithPath: missingPath).deletingLastPathComponent().path)/local.db" + ) + } + + func testPermissionRelaunchSetupIntentPathUsesConfiguredCuedHome() throws { + let homeConfigPath = try writeConfig(#"{"home":" /tmp/cued-home "}"#) XCTAssertEqual( permissionRelaunchSetupIntentPath( - environment: ["CUED_HOME": " /tmp/cued-home "], + arguments: ["Cued", "--config", homeConfigPath], homeDirectory: "/Users/test" ), "/tmp/cued-home/permission-relaunch-setup.intent" ) + let dbConfigPath = try writeConfig(#"{"dbPath":" /tmp/cued/local.db "}"#) XCTAssertEqual( permissionRelaunchSetupIntentPath( - environment: ["CUED_DB_PATH": " /tmp/cued/local.db "], + arguments: ["Cued", "--config", dbConfigPath], homeDirectory: "/Users/test" ), "/tmp/cued/permission-relaunch-setup.intent" ) } + func testDaemonEnvironmentOnlyLoadsCredentialAllowlist() throws { + let homeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cued-daemon-env-\(UUID().uuidString)", isDirectory: true) + tempURLs.append(homeURL) + let configPath = try writeConfig(#"{"home":"\#(homeURL.path)"}"#) + let daemonEnvPath = homeURL.appendingPathComponent("daemon.env") + try FileManager.default.createDirectory( + at: daemonEnvPath.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try """ + CUED_DB_KEY=secret + CUED_PROJECTION_BATCH_SIZE=1000 + CUED_GOOGLE_OAUTH_CLIENT_FILE=/tmp/google.json + """.write(to: daemonEnvPath, atomically: true, encoding: .utf8) + + XCTAssertEqual( + loadConfiguredDaemonEnvironment(arguments: ["Cued", "--config", configPath]), + [ + "CUED_DB_KEY": "secret", + "CUED_GOOGLE_OAUTH_CLIENT_FILE": "/tmp/google.json", + ] + ) + } + func testBuildShellCommandEscapesValuesAndSortsExports() { let command = buildShellCommand( "cued setup", diff --git a/package.json b/package.json index 25e5ead2..7ff04d77 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "better-sqlite3-multiple-ciphers": "^12.4.1", + "dotenv": "^17.4.2", "drizzle-orm": "^0.45.1", "playwright": "^1.57.0", "smol-toml": "^1.6.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d997ac1..9967ad82 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: better-sqlite3-multiple-ciphers: specifier: ^12.4.1 version: 12.9.0 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 drizzle-orm: specifier: ^0.45.1 version: 0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.0) @@ -721,6 +724,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + drizzle-orm@0.45.1: resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} peerDependencies: @@ -1765,6 +1772,8 @@ snapshots: detect-libc@2.1.2: {} + dotenv@17.4.2: {} + drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.0): optionalDependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/bench-daemon-memory-macos.sh b/scripts/bench-daemon-memory-macos.sh index 9e7c26a7..d4cc8452 100755 --- a/scripts/bench-daemon-memory-macos.sh +++ b/scripts/bench-daemon-memory-macos.sh @@ -5,19 +5,19 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST_CLI_PATH="$ROOT_DIR/dist/cli.js" DEFAULT_BASELINE_PATH="$ROOT_DIR/src/runtime/perf/daemon-memory-baseline.json" -DEFAULT_RUN_COUNT="${CUED_BENCH_RUN_COUNT:-3}" -DEFAULT_WARMUP_SECONDS="${CUED_BENCH_WARMUP_SECONDS:-5}" -DEFAULT_SAMPLE_COUNT="${CUED_BENCH_SAMPLE_COUNT:-20}" -DEFAULT_SAMPLE_INTERVAL_SECONDS="${CUED_BENCH_SAMPLE_INTERVAL_SECONDS:-1}" -DEFAULT_STARTUP_ATTEMPTS="${CUED_BENCH_STARTUP_ATTEMPTS:-300}" -DEFAULT_STARTUP_SLEEP_SECONDS="${CUED_BENCH_STARTUP_SLEEP_SECONDS:-0.1}" -DEFAULT_IDLE_CPU_POWER_RUN_COUNT="${CUED_BENCH_IDLE_CPU_POWER_RUN_COUNT:-1}" -DEFAULT_IDLE_CPU_POWER_WARMUP_SECONDS="${CUED_BENCH_IDLE_CPU_POWER_WARMUP_SECONDS:-10}" -DEFAULT_IDLE_CPU_POWER_SAMPLE_COUNT="${CUED_BENCH_IDLE_CPU_POWER_SAMPLE_COUNT:-60}" -DEFAULT_IDLE_CPU_POWER_SAMPLE_INTERVAL_SECONDS="${CUED_BENCH_IDLE_CPU_POWER_SAMPLE_INTERVAL_SECONDS:-10}" -DEFAULT_ACTIVE_SYNC_RUN_COUNT="${CUED_BENCH_ACTIVE_SYNC_RUN_COUNT:-3}" -DEFAULT_ACTIVE_SYNC_SAMPLE_INTERVAL_SECONDS="${CUED_BENCH_ACTIVE_SYNC_SAMPLE_INTERVAL_SECONDS:-0.25}" -DEFAULT_ACTIVE_SYNC_TIMEOUT_SECONDS="${CUED_BENCH_ACTIVE_SYNC_TIMEOUT_SECONDS:-60}" +DEFAULT_RUN_COUNT="3" +DEFAULT_WARMUP_SECONDS="5" +DEFAULT_SAMPLE_COUNT="20" +DEFAULT_SAMPLE_INTERVAL_SECONDS="1" +DEFAULT_STARTUP_ATTEMPTS="300" +DEFAULT_STARTUP_SLEEP_SECONDS="0.1" +DEFAULT_IDLE_CPU_POWER_RUN_COUNT="1" +DEFAULT_IDLE_CPU_POWER_WARMUP_SECONDS="10" +DEFAULT_IDLE_CPU_POWER_SAMPLE_COUNT="60" +DEFAULT_IDLE_CPU_POWER_SAMPLE_INTERVAL_SECONDS="10" +DEFAULT_ACTIVE_SYNC_RUN_COUNT="3" +DEFAULT_ACTIVE_SYNC_SAMPLE_INTERVAL_SECONDS="0.25" +DEFAULT_ACTIVE_SYNC_TIMEOUT_SECONDS="60" SCENARIO="clean_idle" BASELINE_PATH="" @@ -231,17 +231,23 @@ current_time_ms() { node -e 'process.stdout.write(String(Date.now()))' } +runtime_config_path() { + printf '%s\n' "$1/config.json" +} + +write_runtime_config() { + local home_dir="$1" + local db_path="${2:-$home_dir/local.db}" + mkdir -p "$home_dir" + printf '{"home":"%s","dbPath":"%s"}\n' "$home_dir" "$db_path" >"$(runtime_config_path "$home_dir")" +} + run_cli_to_file() { local home_dir="$1" local output_path="$2" shift 2 - local disabled_slack_app_path="$home_dir/disabled/Slack.app/Contents/MacOS/Slack" - local disabled_slack_user_data_dir="$home_dir/disabled/SlackData" - CUED_HOME="$home_dir" \ - CUED_DB_PATH="$home_dir/local.db" \ - CUED_SLACK_APP_BINARY="$disabled_slack_app_path" \ - CUED_SLACK_USER_DATA_DIR="$disabled_slack_user_data_dir" \ - node "$DIST_CLI_PATH" "$@" >"$output_path" + write_runtime_config "$home_dir" + node "$DIST_CLI_PATH" --config "$(runtime_config_path "$home_dir")" "$@" >"$output_path" } run_cli_quiet() { @@ -332,6 +338,9 @@ prepare_home_for_run() { {"displayName":"Perf Beta","company":"Cued","phoneNumbers":["+14155550102"],"emails":["beta@example.com"]}, {"displayName":"Perf Gamma","company":"Cued","phoneNumbers":["+14155550103"],"emails":["gamma@example.com"]} ]} +EOF + cat >"$home_dir/contacts.config.json" < { const { openCuedDatabase } = await import("./dist/db/database.js"); - const db = openCuedDatabase(process.env.CUED_DB_PATH); + const db = openCuedDatabase(process.argv[2]); db.close(); })().catch((error) => { console.error(error); @@ -587,25 +594,11 @@ SCENARIO_NOTE="" for run_number in $(seq 1 "$RUN_COUNT"); do RUN_DIR="$ARTIFACT_ROOT/run-$run_number" HOME_DIR="$RUN_DIR/home" - DISABLED_SLACK_APP_PATH="$HOME_DIR/disabled/Slack.app/Contents/MacOS/Slack" - DISABLED_SLACK_USER_DATA_DIR="$HOME_DIR/disabled/SlackData" mkdir -p "$RUN_DIR" prepare_home_for_run "$HOME_DIR" + write_runtime_config "$HOME_DIR" - DAEMON_ENV=( - "CUED_HOME=$HOME_DIR" - "CUED_DB_PATH=$HOME_DIR/local.db" - "CUED_SLACK_APP_BINARY=$DISABLED_SLACK_APP_PATH" - "CUED_SLACK_USER_DATA_DIR=$DISABLED_SLACK_USER_DATA_DIR" - ) - if [ "$SCENARIO" = "active_sync_projection" ]; then - DAEMON_ENV+=( - "CUED_AUTOSYNC_PLATFORMS=contacts" - "CUED_CONTACTS_JSON_PATH=$HOME_DIR/contacts-benchmark.json" - ) - fi - - env "${DAEMON_ENV[@]}" node "$DIST_CLI_PATH" daemon >"$RUN_DIR/daemon.out" 2>&1 & + node "$DIST_CLI_PATH" --config "$(runtime_config_path "$HOME_DIR")" daemon >"$RUN_DIR/daemon.out" 2>&1 & ACTIVE_ROOT_PID="$!" READY_MS="$(wait_for_daemon_ready "$HOME_DIR")" diff --git a/scripts/bench-gui-responsiveness.sh b/scripts/bench-gui-responsiveness.sh index 278280a7..4712741c 100755 --- a/scripts/bench-gui-responsiveness.sh +++ b/scripts/bench-gui-responsiveness.sh @@ -1,34 +1,69 @@ #!/usr/bin/env bash set -euo pipefail -CLI="${CUED_CLI:-cued}" -DURATION_SECONDS="${CUED_BENCH_DURATION_SECONDS:-60}" -INTERVAL_SECONDS="${CUED_BENCH_INTERVAL_SECONDS:-1}" -TRIGGER_SYNC="${CUED_BENCH_TRIGGER_SYNC:-0}" -OUT="${CUED_BENCH_OUT:-/tmp/cued-gui-responsiveness-$(date +%Y%m%d-%H%M%S).jsonl}" +CLI="cued" +DURATION_SECONDS="60" +INTERVAL_SECONDS="1" +TRIGGER_SYNC="0" +OUT="/tmp/cued-gui-responsiveness-$(date +%Y%m%d-%H%M%S).jsonl" SYNC_OUT="$(mktemp -t cued-bench-sync-resume.out.XXXXXX)" SYNC_ERR="$(mktemp -t cued-bench-sync-resume.err.XXXXXX)" trap 'rm -f "$SYNC_OUT" "$SYNC_ERR"' EXIT +usage() { + cat <&2 + exit 1 + ;; + esac +done + if [[ "$TRIGGER_SYNC" == "1" ]]; then ("$CLI" sync resume >"$SYNC_OUT" 2>"$SYNC_ERR" || true) & fi -CUED_CLI="$CLI" \ -CUED_BENCH_DURATION_SECONDS="$DURATION_SECONDS" \ -CUED_BENCH_INTERVAL_SECONDS="$INTERVAL_SECONDS" \ -CUED_BENCH_OUT="$OUT" \ -python3 - <<'PY' +python3 - "$CLI" "$DURATION_SECONDS" "$INTERVAL_SECONDS" "$OUT" <<'PY' import json -import os import statistics import subprocess +import sys import time -cli = os.environ["CUED_CLI"] -duration_seconds = float(os.environ["CUED_BENCH_DURATION_SECONDS"]) -interval_seconds = float(os.environ["CUED_BENCH_INTERVAL_SECONDS"]) -out = os.environ["CUED_BENCH_OUT"] +cli = sys.argv[1] +duration_seconds = float(sys.argv[2]) +interval_seconds = float(sys.argv[3]) +out = sys.argv[4] METRIC_KEYS = [ "messages", "rawEvents", diff --git a/scripts/build-cued-daemon-app.sh b/scripts/build-cued-daemon-app.sh index ee8e6636..8b90f6ef 100644 --- a/scripts/build-cued-daemon-app.sh +++ b/scripts/build-cued-daemon-app.sh @@ -33,14 +33,13 @@ PERMISSIONS_SCRIPT_SOURCE="$ROOT_DIR/scripts/request-macos-access.sh" APP_ICON_SOURCE="$ROOT_DIR/native/macos/CuedNative/Resources/AppIcon.icns" TRAY_ICON_SOURCE="$ROOT_DIR/native/macos/CuedNative/Resources/trayIconTemplate.png" CUED_MARK_SOURCE="$ROOT_DIR/native/macos/CuedNative/Resources/cued-mark.png" -NODE_PATH="${CUED_NODE_PATH:-$(command -v node)}" +NODE_PATH="$(command -v node)" RUNTIME_SYMLINK_PRUNER="$ROOT_DIR/dist/macos/runtime-symlinks.js" GOOGLE_OAUTH_CLIENT_SOURCE="${CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE:-}" APP_VERSION="$("$NODE_PATH" -p "require(process.argv[1]).version" "$ROOT_DIR/package.json")" +RELEASE_CHANNEL="internal" NODE_VERSION="$("$NODE_PATH" -p 'process.versions.node')" NODE_ARCH="$("$NODE_PATH" -p 'process.arch === "arm64" ? "arm64" : "x64"')" -RELEASE_CHANNEL="${CUED_RELEASE_CHANNEL:-internal}" -DAEMON_COMMAND="${CUED_DAEMON_COMMAND:-\"\$CUED_APP_PATH/Contents/Resources/cued-cli\" daemon}" BETTER_SQLITE3_BINDING_SOURCE="$(find "$ROOT_DIR/node_modules/.pnpm" -path "*/better-sqlite3-multiple-ciphers/build/Release/better_sqlite3.node" | head -n 1)" DEPLOY_STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cued-runtime.XXXXXX")" @@ -50,12 +49,30 @@ cleanup() { trap cleanup EXIT -xml_escape() { - printf '%s' "$1" \ - | sed -e 's/&/\&/g' \ - -e 's//\>/g' -} +while [[ $# -gt 0 ]]; do + case "$1" in + --release-channel) + RELEASE_CHANNEL="${2:-}" + shift 2 + ;; + --help|-h) + echo "Usage: bash scripts/build-cued-daemon-app.sh [--release-channel internal|stable|dev]" >&2 + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +case "$RELEASE_CHANNEL" in + internal|stable|dev) ;; + *) + echo "Unsupported release channel: $RELEASE_CHANNEL" >&2 + exit 1 + ;; +esac sign_app_bundle() { strip_extended_attributes "$APP_BUNDLE" @@ -265,6 +282,9 @@ cp -R "$NODE_RUNTIME_SOURCE_DIR/lib" "$RUNTIME_NODE_ROOT/lib" chmod +x "$RUNTIME_NODE_BIN_DIR/node" ditto --noextattr --norsrc "$PLAYWRIGHT_CHROMIUM_SOURCE_DIR" "$PLAYWRIGHT_CHROMIUM_PAYLOAD_DIR" cp -R "$DEPLOY_STAGING_DIR/." "$RUNTIME_DIR/" +cat > "$RUNTIME_DIR/app-metadata.json" <CuedDBPath - $(xml_escape "$CUED_DB_PATH_OVERRIDE") -EOF -) -fi - cat > "$CONTENTS_DIR/Info.plist" < @@ -381,9 +392,6 @@ cat > "$CONTENTS_DIR/Info.plist" < NSContactsUsageDescription Cued reads local contacts so your local message database can resolve people consistently across platforms. - CuedDaemonCommand - $(xml_escape "$DAEMON_COMMAND") -$INFO_PLIST_DB_PATH_BLOCK EOF @@ -398,28 +406,13 @@ while [[ -L "\$SCRIPT_SOURCE" ]]; do [[ "\$SCRIPT_SOURCE" != /* ]] && SCRIPT_SOURCE="\$SCRIPT_DIR/\$SCRIPT_SOURCE" done SCRIPT_DIR="\$(cd "\$(dirname "\$SCRIPT_SOURCE")" && pwd)" -APP_EXEC="\$SCRIPT_DIR/../MacOS/$APP_EXECUTABLE_NAME" APP_BUNDLE_PATH="\$(cd "\$SCRIPT_DIR/../.." && pwd)" RUNTIME_ROOT="\$SCRIPT_DIR/cued-runtime" -SCRIPT_ROOT="\$SCRIPT_DIR/scripts" NODE_BIN="\$SCRIPT_DIR/runtime/node/bin/node" -HELPER_BINARY="\$SCRIPT_DIR/helpers/$HELPER_NAME" export PATH="\$(dirname "\$NODE_BIN"):\$PATH" -export CUED_APP_PATH="\${CUED_APP_PATH:-\$APP_BUNDLE_PATH}" -export CUED_BUNDLED_RUNTIME_ROOT="\${CUED_BUNDLED_RUNTIME_ROOT:-\$RUNTIME_ROOT}" -export CUED_BUNDLED_SCRIPT_ROOT="\${CUED_BUNDLED_SCRIPT_ROOT:-\$SCRIPT_ROOT}" -export CUED_NATIVE_BINARY="\${CUED_NATIVE_BINARY:-\$HELPER_BINARY}" -export CUED_IMESSAGE_NATIVE_BINARY="\${CUED_IMESSAGE_NATIVE_BINARY:-\$HELPER_BINARY}" -export CUED_CONTACTS_NATIVE_BINARY="\${CUED_CONTACTS_NATIVE_BINARY:-\$HELPER_BINARY}" -export CUED_AUTH_NATIVE_BINARY="\${CUED_AUTH_NATIVE_BINARY:-\$APP_EXEC}" -export CUED_CHROMIUM_EXECUTABLE_PATH="\${CUED_CHROMIUM_EXECUTABLE_PATH:-\$SCRIPT_DIR/runtime/chromium/chrome/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing}" if [[ -f "\$SCRIPT_DIR/oauth/google-oauth-client.json" ]]; then export CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE="\${CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE:-\$SCRIPT_DIR/oauth/google-oauth-client.json}" fi -export CUED_SLACK_HELPER_BINARY="\${CUED_SLACK_HELPER_BINARY:-\$SCRIPT_DIR/helpers/cued-slack-helper}" -export CUED_WHATSAPP_HELPER_BINARY="\${CUED_WHATSAPP_HELPER_BINARY:-\$SCRIPT_DIR/helpers/cued-whatsapp-helper}" -export CUED_APP_VERSION="$APP_VERSION" -export CUED_RELEASE_CHANNEL="$RELEASE_CHANNEL" exec "\$NODE_BIN" "\$RUNTIME_ROOT/dist/cli.js" "\$@" EOF chmod +x "$RESOURCES_DIR/cued-cli" diff --git a/scripts/build-cued-release-artifacts.sh b/scripts/build-cued-release-artifacts.sh index acaf6258..e7a2a703 100644 --- a/scripts/build-cued-release-artifacts.sh +++ b/scripts/build-cued-release-artifacts.sh @@ -23,9 +23,9 @@ if [[ -z "${CUED_NOTARY_PROFILE:-}" ]]; then exit 1 fi -export CUED_RELEASE_CHANNEL="${CUED_RELEASE_CHANNEL:-stable}" +RELEASE_CHANNEL="stable" -if [[ "$CUED_RELEASE_CHANNEL" == "stable" && -z "${CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE:-}" ]]; then +if [[ "$RELEASE_CHANNEL" == "stable" && -z "${CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE:-}" ]]; then echo "CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE is required for stable Gmail OAuth releases" >&2 exit 1 fi @@ -139,7 +139,7 @@ sign_nested_code_containers() { done < <(find "$APP_BUNDLE/Contents" -type f -path '*/Contents/Info.plist' -print0 | sort -rz) } -bash "$APP_BUILDER" >/dev/null +bash "$APP_BUILDER" --release-channel "$RELEASE_CHANNEL" >/dev/null sign_nested_binaries sign_embedded_archives sign_nested_code_containers diff --git a/scripts/build-cued-release-metadata.mjs b/scripts/build-cued-release-metadata.mjs index f8ff1b9e..fc8c1dc0 100755 --- a/scripts/build-cued-release-metadata.mjs +++ b/scripts/build-cued-release-metadata.mjs @@ -8,13 +8,14 @@ import { join, resolve } from "node:path"; const rootDir = resolve(import.meta.dirname, ".."); const distDir = join(rootDir, "native", "macos", "dist"); -const version = - process.env.CUED_RELEASE_VERSION ?? - JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")).version; -const tag = process.env.CUED_RELEASE_TAG ?? `v${version}`; -const repo = process.env.CUED_RELEASE_REPO ?? "Cue-d/cued"; -const channel = process.env.CUED_RELEASE_CHANNEL ?? "stable"; -const publishedAt = process.env.CUED_RELEASE_PUBLISHED_AT ?? new Date().toISOString(); +const releaseConfig = { + repo: "Cue-d/cued", + channel: "stable", +}; + +const version = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")).version; +const tag = `v${version}`; +const publishedAt = new Date().toISOString(); const artifactNames = { dmg: "Cued.dmg", @@ -51,18 +52,18 @@ function sha256(fileName) { const metadata = { version, tag, - channel, + channel: releaseConfig.channel, architecture: "arm64", publishedAt, artifacts: { dmg: { name: artifactNames.dmg, - url: `https://github.com/${repo}/releases/download/${tag}/${artifactNames.dmg}`, + url: `https://github.com/${releaseConfig.repo}/releases/download/${tag}/${artifactNames.dmg}`, sha256: sha256(artifactNames.dmg), }, tarball: { name: artifactNames.tarball, - url: `https://github.com/${repo}/releases/download/${tag}/${artifactNames.tarball}`, + url: `https://github.com/${releaseConfig.repo}/releases/download/${tag}/${artifactNames.tarball}`, sha256: sha256(artifactNames.tarball), }, }, diff --git a/scripts/fetch-node-runtime-macos.sh b/scripts/fetch-node-runtime-macos.sh index 8660d4be..761fde56 100644 --- a/scripts/fetch-node-runtime-macos.sh +++ b/scripts/fetch-node-runtime-macos.sh @@ -9,7 +9,7 @@ fi NODE_VERSION="$1" NODE_ARCH="$2" -CACHE_DIR="${CUED_NODE_RUNTIME_CACHE_DIR:-${TMPDIR:-/tmp}/cued-node-runtime}" +CACHE_DIR="${TMPDIR:-/tmp}/cued-node-runtime" DIST_NAME="node-v${NODE_VERSION}-darwin-${NODE_ARCH}" ARCHIVE_PATH="$CACHE_DIR/${DIST_NAME}.tar.gz" RUNTIME_DIR="$CACHE_DIR/${DIST_NAME}" diff --git a/scripts/fetch-playwright-chromium-macos.sh b/scripts/fetch-playwright-chromium-macos.sh index bac27ee8..d4e9c46f 100644 --- a/scripts/fetch-playwright-chromium-macos.sh +++ b/scripts/fetch-playwright-chromium-macos.sh @@ -3,9 +3,9 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -NODE_BIN="${CUED_NODE_PATH:-$(command -v node)}" +NODE_BIN="$(command -v node)" PLAYWRIGHT_CLI="$ROOT_DIR/node_modules/.bin/playwright" -PLAYWRIGHT_CACHE_DIR="${CUED_PLAYWRIGHT_BROWSER_CACHE_DIR:-${TMPDIR:-/tmp}/cued-playwright-browsers}" +PLAYWRIGHT_CACHE_DIR="${TMPDIR:-/tmp}/cued-playwright-browsers" if [[ ! -x "$PLAYWRIGHT_CLI" ]]; then echo "Playwright CLI not found at $PLAYWRIGHT_CLI" >&2 diff --git a/scripts/install-cued-release.sh b/scripts/install-cued-release.sh index 392747bd..b599bb4d 100755 --- a/scripts/install-cued-release.sh +++ b/scripts/install-cued-release.sh @@ -7,15 +7,44 @@ if [[ "$(uname -s)" != "Darwin" ]]; then exit 1 fi -REPO="${CUED_RELEASE_REPO:-Cue-d/cued}" -CHANNEL="${CUED_RELEASE_CHANNEL:-stable}" -API_BASE="${CUED_RELEASE_API_BASE:-https://api.github.com}" +REPO="Cue-d/cued" +CHANNEL="stable" +API_BASE="https://api.github.com" APP_NAME="Cued.app" TARBALL_NAME="cued-macos-arm64.tar.gz" -DESTINATION="${CUED_DESTINATION:-}" -OPEN_APP="${CUED_OPEN_APP:-1}" +DESTINATION="" +OPEN_APP="1" TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cued-install.XXXXXX")" +while [[ $# -gt 0 ]]; do + case "$1" in + --channel|--release-channel) + CHANNEL="${2:-}" + shift 2 + ;; + --destination) + DESTINATION="${2:-}" + shift 2 + ;; + --no-open) + OPEN_APP="0" + shift + ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +case "$CHANNEL" in + stable|internal) ;; + *) + echo "Unsupported Cued release channel: $CHANNEL" >&2 + exit 1 + ;; +esac + if [[ "$(uname -m)" != "arm64" ]]; then echo "Cued internal releases currently support Apple Silicon Macs only." >&2 exit 1 @@ -64,15 +93,17 @@ validate_app_bundle() { spctl --assess --type execute "$app_path" >/dev/null } -release_json="$(curl -fsSL -H 'Accept: application/vnd.github+json' "${API_BASE}/repos/${REPO}/releases")" +release_json_path="$TMP_DIR/releases.json" +curl -fsSL -H 'Accept: application/vnd.github+json' "${API_BASE}/repos/${REPO}/releases" -o "$release_json_path" -release_info="$(RELEASE_JSON="$release_json" RELEASE_CHANNEL="$CHANNEL" python3 - <<'PY' +release_info="$(python3 - "$CHANNEL" "$release_json_path" <<'PY' import json -import os +import sys -channel = os.environ["RELEASE_CHANNEL"] +channel = sys.argv[1] desired_prerelease = channel != "stable" -releases = json.loads(os.environ["RELEASE_JSON"]) +with open(sys.argv[2], encoding="utf-8") as handle: + releases = json.load(handle) release = next((r for r in releases if bool(r.get("prerelease")) == desired_prerelease), None) if release is None and releases: release = releases[0] diff --git a/scripts/request-macos-access.sh b/scripts/request-macos-access.sh index 4fb686ab..9f4aea1e 100755 --- a/scripts/request-macos-access.sh +++ b/scripts/request-macos-access.sh @@ -5,22 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" NATIVE_PACKAGE_DIR="$ROOT_DIR/native/macos/CuedNative" -DEFAULT_NATIVE_BINARY="$NATIVE_PACKAGE_DIR/.build/release/CuedNative" -if [[ -z "${CUED_APP_PATH:-}" ]]; then - APP_BUNDLE_CANDIDATE="$(cd "$SCRIPT_DIR/../.." 2>/dev/null && pwd || true)" - if [[ -n "$APP_BUNDLE_CANDIDATE" && "$(basename "$APP_BUNDLE_CANDIDATE")" == *.app ]]; then - CUED_APP_PATH="$APP_BUNDLE_CANDIDATE" - fi -fi -IS_BUNDLED_APP=0 -if [[ -n "${CUED_APP_PATH:-}" && -d "${CUED_APP_PATH}/Contents/Resources" ]]; then - IS_BUNDLED_APP=1 -fi -if [[ $IS_BUNDLED_APP -eq 1 ]]; then - DEFAULT_NATIVE_BINARY="${CUED_APP_PATH}/Contents/Resources/helpers/cued-native-helper" -fi -NATIVE_BINARY="${CUED_NATIVE_BINARY:-$DEFAULT_NATIVE_BINARY}" -PERMISSION_TARGET="${CUED_PERMISSION_TARGET:-${CUED_APP_PATH:-$NATIVE_BINARY}}" +APP_PATH="" REQUEST_CONTACTS=0 REQUEST_FULL_DISK=0 @@ -59,6 +44,14 @@ Notes: EOF } +detect_packaged_app_path() { + local candidate + candidate="$(cd "$SCRIPT_DIR/../../.." 2>/dev/null && pwd || true)" + if [[ -n "$candidate" && "$(basename "$candidate")" == *.app ]]; then + printf '%s\n' "$candidate" + fi +} + ensure_macos() { if [[ "$(uname -s)" != "Darwin" ]]; then die "this script only works on macOS" @@ -158,6 +151,16 @@ while [[ $# -gt 0 ]]; do shift done +APP_PATH="${APP_PATH:-$(detect_packaged_app_path)}" +IS_BUNDLED_APP=0 +DEFAULT_NATIVE_BINARY="$NATIVE_PACKAGE_DIR/.build/release/CuedNative" +if [[ -n "$APP_PATH" && -d "$APP_PATH/Contents/Resources" ]]; then + IS_BUNDLED_APP=1 + DEFAULT_NATIVE_BINARY="$APP_PATH/Contents/Resources/helpers/cued-native-helper" +fi +NATIVE_BINARY="$DEFAULT_NATIVE_BINARY" +PERMISSION_TARGET="${APP_PATH:-$NATIVE_BINARY}" + ensure_macos if [[ $REQUEST_CONTACTS -eq 0 && $REQUEST_FULL_DISK -eq 0 ]]; then diff --git a/scripts/smoke-auth-lifecycle.ts b/scripts/smoke-auth-lifecycle.ts index 2dbf21c9..61a56c1b 100644 --- a/scripts/smoke-auth-lifecycle.ts +++ b/scripts/smoke-auth-lifecycle.ts @@ -23,11 +23,31 @@ type IntegrationRow = { metadata_json: string | null; }; +function parseArgs(argv: string[]): { home?: string; keepHome: boolean } { + const parsed: { home?: string; keepHome: boolean } = { keepHome: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--home") { + parsed.home = argv[index + 1]; + index += 1; + continue; + } + if (arg === "--keep-home") { + parsed.keepHome = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return parsed; +} + +const args = parseArgs(process.argv.slice(2)); const runId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -const cuedHome = - process.env.CUED_AUTH_SMOKE_HOME || mkdtempSync(join(tmpdir(), "cued-auth-smoke-")); +const cuedHome = args.home || mkdtempSync(join(tmpdir(), "cued-auth-smoke-")); +const cuedConfigPath = join(cuedHome, "config.json"); +const authTestHooksPath = join(cuedHome, "auth-test-hooks.json"); const shadowKeychainService = `so.cued.desktop.smoke.auth.${runId}`; -const cleanup = process.env.CUED_AUTH_SMOKE_KEEP_HOME !== "1"; +const cleanup = !args.keepHome; const cases: PlatformCase[] = [ { @@ -129,16 +149,24 @@ function extractJson(stdout: string): unknown { } function runCued(args: string[], extraEnv: Record = {}): unknown { - const stdout = execFileSync("pnpm", ["exec", "tsx", "src/cli.ts", ...args], { - cwd: process.cwd(), - encoding: "utf8", - env: { - ...process.env, - CUED_HOME: cuedHome, - ...extraEnv, + mkdirSync(cuedHome, { recursive: true }); + writeFileSync( + cuedConfigPath, + JSON.stringify({ home: cuedHome, dbPath: join(cuedHome, "local.db") }), + ); + const stdout = execFileSync( + "pnpm", + ["exec", "tsx", "src/cli.ts", "--config", cuedConfigPath, ...args], + { + cwd: process.cwd(), + encoding: "utf8", + env: { + ...process.env, + ...extraEnv, + }, + stdio: ["ignore", "pipe", "pipe"], }, - stdio: ["ignore", "pipe", "pipe"], - }); + ); return extractJson(stdout); } @@ -216,7 +244,7 @@ function deleteShadowKeychain(service: string, account: string): void { security(["delete-generic-password", "-s", service, "-a", account]); } -function makeFakeEnv(testCase: PlatformCase, accountKey: string): Record { +function writeFakeAuthResult(testCase: PlatformCase, accountKey: string): void { const keychainAccount = testCase.runtime === "chromium" ? testCase.finalAccount : accountKey; const fake = { state: "authenticated", @@ -228,16 +256,18 @@ function makeFakeEnv(testCase: PlatformCase, accountKey: string): Record Reuse a specific sandbox root instead of creating a new temp one + --timeout Wait time for daemon/app readiness checks --applications Install the built app into /Applications/Cued.app and smoke test that path --skip-build Skip pnpm check:ci-local and pnpm build:app:macos --cleanup Remove the sandbox on success @@ -57,6 +58,11 @@ while [[ $# -gt 0 ]]; do [[ -n "$SANDBOX_ROOT" ]] || die "--sandbox requires a path" shift 2 ;; + --timeout) + TIMEOUT_SECONDS="${2:-}" + [[ -n "$TIMEOUT_SECONDS" ]] || die "--timeout requires seconds" + shift 2 + ;; --applications) INSTALL_MODE="applications" shift @@ -94,6 +100,7 @@ fi APP_UNDER_TEST="$SANDBOX_ROOT/Cued.app" HOME_UNDER_TEST="$SANDBOX_ROOT/home" CUED_HOME_UNDER_TEST="$HOME_UNDER_TEST/.cued" +CUED_CONFIG_UNDER_TEST="$CUED_HOME_UNDER_TEST/config.json" ARTIFACTS_DIR="$SANDBOX_ROOT/artifacts" APP_LOG="$SANDBOX_ROOT/app.log" NOTES_PATH="$ARTIFACTS_DIR/notes.md" @@ -104,7 +111,10 @@ INSTALLED_APP_BACKUP="$SANDBOX_ROOT/original-installed-Cued.app" APP_PID="" ONBOARDING_PROCESS_NAME="" -mkdir -p "$HOME_UNDER_TEST" "$ARTIFACTS_DIR" +mkdir -p "$CUED_HOME_UNDER_TEST" "$ARTIFACTS_DIR" +cat >"$CUED_CONFIG_UNDER_TEST" </dev/null 2>&1; then @@ -146,10 +156,7 @@ snapshot_path_manifest() { smoke_env() { env \ HOME="$HOME_UNDER_TEST" \ - CUED_HOME="$CUED_HOME_UNDER_TEST" \ - CUED_DB_PATH="$CUED_HOME_UNDER_TEST/local.db" \ - CUED_SKIP_AUTO_PREREQUISITES=1 \ - "$@" + "$@" --config "$CUED_CONFIG_UNDER_TEST" } run_cli() { diff --git a/scripts/validate-cued-release-artifact.sh b/scripts/validate-cued-release-artifact.sh index 17d0c252..c47ce0b0 100755 --- a/scripts/validate-cued-release-artifact.sh +++ b/scripts/validate-cued-release-artifact.sh @@ -9,8 +9,8 @@ INFO_PLIST="$APP_BUNDLE/Contents/Info.plist" RUNTIME_PATH="$APP_BUNDLE/Contents/Resources/cued-runtime" RESOURCES_PATH="$APP_BUNDLE/Contents/Resources" SKILL_PATH="$APP_BUNDLE/Contents/Resources/skills/cued/SKILL.md" -EXPECTED_VERSION="${CUED_RELEASE_VERSION:-$(node -p "require(process.argv[1]).version" "$ROOT_DIR/package.json")}" -EXPECTED_TAG="${CUED_RELEASE_TAG:-v$EXPECTED_VERSION}" +EXPECTED_VERSION="$(node -p "require(process.argv[1]).version" "$ROOT_DIR/package.json")" +EXPECTED_TAG="v$EXPECTED_VERSION" if [[ ! -d "$APP_BUNDLE" ]]; then echo "Cued.app not found at $APP_BUNDLE" >&2 diff --git a/src/cli-contacts-memory.test.ts b/src/cli-contacts-memory.test.ts index ace1d835..a15d9e93 100644 --- a/src/cli-contacts-memory.test.ts +++ b/src/cli-contacts-memory.test.ts @@ -47,12 +47,18 @@ describe("contacts memory CLI", () => { } function runCli(home: string, args: string[]): string { - return execFileSync("pnpm", ["--silent", "exec", "tsx", "src/cli.ts", ...args], { - cwd: process.cwd(), - env: { ...process.env, CUED_HOME: home }, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + const configPath = join(home, "config.json"); + writeFileSync(configPath, JSON.stringify({ home, dbPath: join(home, "local.db") }), "utf8"); + return execFileSync( + "pnpm", + ["--silent", "exec", "tsx", "src/cli.ts", "--config", configPath, ...args], + { + cwd: process.cwd(), + env: process.env, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }, + ); } it("adds, supersedes, and lists current contact memories", () => { diff --git a/src/cli-paths.test.ts b/src/cli-paths.test.ts index 0eb92ee7..be702d43 100644 --- a/src/cli-paths.test.ts +++ b/src/cli-paths.test.ts @@ -1,8 +1,12 @@ -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { isDirectInvocation, resolvePermissionsScriptPath } from "./cli.js"; +import { + isDirectInvocation, + resolveBundledScriptPath, + resolvePermissionsScriptPath, +} from "./cli.js"; describe("cli path resolution", () => { const tempDirs: string[] = []; @@ -23,6 +27,19 @@ describe("cli path resolution", () => { ); }); + it("finds scripts staged beside the packaged runtime", () => { + const appRoot = mkdtempSync(join(tmpdir(), "cued-packaged-scripts-")); + tempDirs.push(appRoot); + const distRoot = join(appRoot, "Contents", "Resources", "cued-runtime", "dist"); + const scriptDir = join(appRoot, "Contents", "Resources", "scripts"); + mkdirSync(distRoot, { recursive: true }); + mkdirSync(scriptDir, { recursive: true }); + const scriptPath = join(scriptDir, "request-macos-access.sh"); + writeFileSync(scriptPath, "#!/bin/sh\n"); + + expect(resolveBundledScriptPath("request-macos-access.sh", distRoot)).toBe(scriptPath); + }); + it("treats symlinked invocation paths as direct execution", () => { const dir = mkdtempSync(join(tmpdir(), "cued-cli-path-")); tempDirs.push(dir); diff --git a/src/cli.ts b/src/cli.ts index 3cd03997..da2a8d63 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import "./core/env.js"; import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, realpathSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -7,7 +8,12 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { DaemonRequestTimeoutError, sendDaemonRequest } from "./client.js"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "./core/app-metadata.js"; -import { CUED_DB_PATH, CUED_SOCKET_PATH, ensureCuedDirs } from "./core/config.js"; +import { + CUED_DB_PATH, + CUED_SOCKET_PATH, + ensureCuedDirs, + stripRuntimeConfigArgs, +} from "./core/config.js"; import { resolveHostOS } from "./core/platform-capabilities.js"; import { openCuedDatabase, @@ -57,7 +63,7 @@ import { } from "./runtime/logs.js"; import { readMenuBarStatusCache } from "./runtime/menu-bar-status-cache.js"; import { buildOnboardingSnapshot } from "./runtime/onboarding.js"; -import { runProjectionWorkerFromEnv } from "./runtime/projection/worker.js"; +import { runProjectionWorkerFromStdin } from "./runtime/projection/worker.js"; import { checkForUpdates, clearUpdateHelperPendingState, @@ -79,13 +85,11 @@ import { const DIST_ROOT = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(DIST_ROOT, ".."); -export function resolveBundledScriptPath(scriptName: string): string | null { +export function resolveBundledScriptPath(scriptName: string, distRoot = DIST_ROOT): string | null { const candidates = [ - process.env.CUED_BUNDLED_SCRIPT_ROOT - ? join(process.env.CUED_BUNDLED_SCRIPT_ROOT, scriptName) - : null, - join(DIST_ROOT, "../scripts", scriptName), - ].filter((value): value is string => Boolean(value)); + join(distRoot, "../scripts", scriptName), + join(distRoot, "../../scripts", scriptName), + ]; return candidates.find((candidate) => existsSync(candidate)) ?? null; } @@ -428,7 +432,7 @@ function isUnsupportedDaemonCommand(response: { ok: boolean; error?: string }): async function main(): Promise { ensureCuedDirs(); - const args = process.argv.slice(2); + const args = stripRuntimeConfigArgs(process.argv.slice(2)); const [command, subcommand, ...rest] = args; if (!command || command === "help" || command === "--help" || command === "-h") { @@ -442,7 +446,7 @@ async function main(): Promise { } if (command === "__projection-worker") { - await runProjectionWorkerFromEnv(); + await runProjectionWorkerFromStdin(); return; } diff --git a/src/client.ts b/src/client.ts index 3da3bd6c..c311ba6d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ import { CUED_SOCKET_PATH } from "./core/config.js"; import type { DaemonRequest, DaemonResponse } from "./runtime/ipc.js"; const DAEMON_CONNECT_RETRY_DELAYS_MS = [100, 250, 500] as const; -const DEFAULT_DAEMON_REQUEST_TIMEOUT_MS = 10_000; +const DAEMON_REQUEST_TIMEOUT_MS = 10_000; export class DaemonRequestTimeoutError extends Error { constructor( @@ -97,10 +97,7 @@ function sendSingleDaemonRequest(request: DaemonRequest): Promise 0 - ? Math.trunc(configured) - : DEFAULT_DAEMON_REQUEST_TIMEOUT_MS; + return DAEMON_REQUEST_TIMEOUT_MS; } function isRetriableDaemonConnectionError(error: unknown): boolean { diff --git a/src/core/app-metadata.test.ts b/src/core/app-metadata.test.ts index 2725378e..fc1a296a 100644 --- a/src/core/app-metadata.test.ts +++ b/src/core/app-metadata.test.ts @@ -5,14 +5,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; describe("app metadata", () => { afterEach(() => { - vi.unstubAllEnvs(); vi.resetModules(); }); - it("falls back to the repo package version when env vars are unset", async () => { - vi.stubEnv("CUED_APP_VERSION", undefined); - vi.stubEnv("npm_package_version", undefined); - + it("falls back to the repo package version when no version is configured", async () => { const expectedVersion = ( JSON.parse( readFileSync( @@ -26,4 +22,10 @@ describe("app metadata", () => { expect(metadata.getCurrentAppVersion()).toBe(expectedVersion); }); + + it("uses dev as the source checkout release channel fallback", async () => { + const metadata = await import("./app-metadata.js"); + + expect(metadata.getCurrentReleaseChannel()).toBe("dev"); + }); }); diff --git a/src/core/app-metadata.ts b/src/core/app-metadata.ts index 3b236985..c7219ffd 100644 --- a/src/core/app-metadata.ts +++ b/src/core/app-metadata.ts @@ -1,4 +1,5 @@ -import { readFileSync } from "node:fs"; +import "./env.js"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -6,15 +7,39 @@ export type ReleaseChannel = "internal" | "stable" | "dev"; const FALLBACK_APP_VERSION = "0.1.0"; const FALLBACK_RELEASE_CHANNEL: ReleaseChannel = "dev"; +const RUNTIME_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const BUNDLED_METADATA_PATH = join(RUNTIME_ROOT, "app-metadata.json"); + +export const APP_METADATA_CONFIG = { + version: null as string | null, + releaseChannel: null as ReleaseChannel | null, +}; + +function isReleaseChannel(value: unknown): value is ReleaseChannel { + return value === "internal" || value === "stable" || value === "dev"; +} + +function readBundledMetadata(): { version?: string; releaseChannel?: ReleaseChannel } { + if (!existsSync(BUNDLED_METADATA_PATH)) { + return {}; + } + try { + const parsed = JSON.parse(readFileSync(BUNDLED_METADATA_PATH, "utf8")) as { + version?: unknown; + releaseChannel?: unknown; + }; + return { + version: typeof parsed.version === "string" ? parsed.version : undefined, + releaseChannel: isReleaseChannel(parsed.releaseChannel) ? parsed.releaseChannel : undefined, + }; + } catch { + return {}; + } +} function packageVersionFallback(): string { try { - const packageJsonPath = join( - dirname(fileURLToPath(import.meta.url)), - "..", - "..", - "package.json", - ); + const packageJsonPath = join(RUNTIME_ROOT, "package.json"); const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; return typeof parsed.version === "string" ? parsed.version : FALLBACK_APP_VERSION; } catch { @@ -23,15 +48,13 @@ function packageVersionFallback(): string { } export function getCurrentAppVersion(): string { - return ( - process.env.CUED_APP_VERSION ?? process.env.npm_package_version ?? packageVersionFallback() - ); + return APP_METADATA_CONFIG.version ?? readBundledMetadata().version ?? packageVersionFallback(); } export function getCurrentReleaseChannel(): ReleaseChannel { - const value = process.env.CUED_RELEASE_CHANNEL; - if (value === "internal" || value === "stable" || value === "dev") { - return value; - } - return FALLBACK_RELEASE_CHANNEL; + return ( + APP_METADATA_CONFIG.releaseChannel ?? + readBundledMetadata().releaseChannel ?? + FALLBACK_RELEASE_CHANNEL + ); } diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 627ca86e..62998695 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,34 +1,46 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; describe("config path resolution", () => { - afterEach(() => { - vi.resetModules(); - vi.unstubAllEnvs(); - }); + it("uses configured home for runtime paths", async () => { + const config = await import("./config.js"); - it("uses CUED_HOME for all runtime paths", async () => { - vi.stubEnv("CUED_HOME", "/tmp/cued-home"); - vi.stubEnv("CUED_DB_PATH", undefined); + expect(config.resolveCuedHome({ home: "/tmp/cued-home" })).toBe("/tmp/cued-home"); + expect(config.resolveCuedDbPath({ home: "/tmp/cued-home" })).toBe("/tmp/cued-home/local.db"); + }); + it("derives the cued home from dbPath when only the db path is configured", async () => { const config = await import("./config.js"); - expect(config.CUED_HOME).toBe("/tmp/cued-home"); - expect(config.CUED_DB_PATH).toBe("/tmp/cued-home/local.db"); - expect(config.CUED_SOCKET_PATH).toBe("/tmp/cued-home/cued.sock"); - expect(config.CUED_DAEMON_LOCK_PATH).toBe("/tmp/cued-home/daemon.lock"); - expect(config.CUED_MENU_BAR_LOCK_PATH).toBe("/tmp/cued-home/menu-bar.lock"); - expect(config.CUED_DAEMON_LOG_PATH).toBe("/tmp/cued-home/logs/daemon.log"); + expect(config.resolveCuedHome({ dbPath: "/tmp/cued-db/local.db" })).toBe("/tmp/cued-db"); + expect(config.resolveCuedDbPath({ dbPath: "/tmp/cued-db/local.db" })).toBe( + "/tmp/cued-db/local.db", + ); }); - it("derives the cued home from CUED_DB_PATH when only the db path is overridden", async () => { - vi.stubEnv("CUED_HOME", undefined); - vi.stubEnv("CUED_DB_PATH", "/tmp/cued-db/local.db"); + it("strips runtime config args before command parsing", async () => { + const config = await import("./config.js"); + + expect(config.stripRuntimeConfigArgs(["--config", "/tmp/cued.json", "daemon"])).toEqual([ + "daemon", + ]); + expect(config.stripRuntimeConfigArgs(["--cued-config=/tmp/cued.json", "status"])).toEqual([ + "status", + ]); + }); + it("resolves packaged Chromium beside the bundled runtime", async () => { const config = await import("./config.js"); + const existingPaths = new Set([ + "/Applications/Cued.app/Contents/Resources/runtime/chromium/chrome/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + ]); - expect(config.CUED_HOME).toBe("/tmp/cued-db"); - expect(config.CUED_DB_PATH).toBe("/tmp/cued-db/local.db"); - expect(config.CUED_BROWSER_DIR).toBe("/tmp/cued-db/browser"); - expect(config.CUED_DAEMON_LOCK_PATH).toBe("/tmp/cued-db/daemon.lock"); + expect( + config.resolveChromiumExecutablePath( + "/Applications/Cued.app/Contents/Resources/cued-runtime", + (path) => existingPaths.has(path), + ), + ).toBe( + "/Applications/Cued.app/Contents/Resources/runtime/chromium/chrome/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", + ); }); }); diff --git a/src/core/config.ts b/src/core/config.ts index cee6be2b..adab751e 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -1,32 +1,126 @@ -import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import "./env.js"; +import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AdapterPlatform } from "./types/provider.js"; -function getConfiguredPath(name: "CUED_HOME" | "CUED_DB_PATH", env = process.env): string | null { - const raw = env[name]?.trim(); - return raw ? raw : null; +type ProjectionBacklogTier = { + minEvents: number; + batchSize: number; + continueDelayMs: number | null; +}; + +type RuntimeConfigFile = { + home?: unknown; + dbPath?: unknown; +}; + +type FakeAuthRuntime = "chromium" | "qrNative"; + +const CORE_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(CORE_DIR, "../.."); +const CHROMIUM_EXECUTABLE_RELATIVE_PATH = + "runtime/chromium/chrome/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"; +const DEFAULT_CUED_HOME = join(homedir(), ".cued"); +const DEFAULT_CONFIG_FILE = join(DEFAULT_CUED_HOME, "config.json"); +const CONFIG_ARG_NAMES = new Set(["--config", "--cued-config"]); + +function trimPath(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function runtimeConfigPathFromArgs(args: string[]): { path: string | null; explicit: boolean } { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (CONFIG_ARG_NAMES.has(arg)) { + return { path: trimPath(args[index + 1]), explicit: true }; + } + for (const name of CONFIG_ARG_NAMES) { + const prefix = `${name}=`; + if (arg.startsWith(prefix)) { + return { path: trimPath(arg.slice(prefix.length)), explicit: true }; + } + } + } + + return { path: DEFAULT_CONFIG_FILE, explicit: false }; +} + +function readRuntimeConfig(args = process.argv.slice(2)): RuntimeConfigFile { + const { path, explicit } = runtimeConfigPathFromArgs(args); + if (!path) { + return {}; + } + if (!existsSync(path)) { + if (explicit) { + throw new Error(`Cued config file does not exist: ${path}`); + } + return {}; + } + + const parsed = JSON.parse(readFileSync(path, "utf8")) as RuntimeConfigFile; + return parsed && typeof parsed === "object" ? parsed : {}; +} + +function runtimeConfigArgsFromArgs(args: string[]): string[] { + const { path, explicit } = runtimeConfigPathFromArgs(args); + return explicit && path ? ["--config", path] : []; +} + +export function getRuntimeConfigArgs(): string[] { + return runtimeConfigArgsFromArgs(process.argv.slice(2)); +} + +export function stripRuntimeConfigArgs(args: string[]): string[] { + const stripped: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (CONFIG_ARG_NAMES.has(arg)) { + index += 1; + continue; + } + if ([...CONFIG_ARG_NAMES].some((name) => arg.startsWith(`${name}=`))) { + continue; + } + stripped.push(arg); + } + return stripped; } -export function resolveCuedHome(env = process.env): string { - const configuredHome = getConfiguredPath("CUED_HOME", env); +export function resolveCuedHome(runtimeConfig: RuntimeConfigFile = readRuntimeConfig()): string { + const configuredHome = trimPath(runtimeConfig.home); if (configuredHome) { return configuredHome; } - const configuredDbPath = getConfiguredPath("CUED_DB_PATH", env); + const configuredDbPath = trimPath(runtimeConfig.dbPath); if (configuredDbPath) { return dirname(configuredDbPath); } - return join(homedir(), ".cued"); + return DEFAULT_CUED_HOME; } -export function resolveCuedDbPath(env = process.env): string { - return getConfiguredPath("CUED_DB_PATH", env) ?? join(resolveCuedHome(env), "local.db"); +export function resolveCuedDbPath(runtimeConfig: RuntimeConfigFile = readRuntimeConfig()): string { + return trimPath(runtimeConfig.dbPath) ?? join(resolveCuedHome(runtimeConfig), "local.db"); } -export const CUED_HOME = resolveCuedHome(); -export const CUED_DB_PATH = resolveCuedDbPath(); +export function resolveChromiumExecutablePath( + runtimeRoot = REPO_ROOT, + pathExists: (path: string) => boolean = existsSync, +): string { + const candidates = [ + resolve(runtimeRoot, CHROMIUM_EXECUTABLE_RELATIVE_PATH), + resolve(runtimeRoot, "..", CHROMIUM_EXECUTABLE_RELATIVE_PATH), + ]; + return candidates.find(pathExists) ?? candidates[0]!; +} + +const RUNTIME_CONFIG = readRuntimeConfig(); + +export const CUED_HOME = resolveCuedHome(RUNTIME_CONFIG); +export const CUED_DB_PATH = resolveCuedDbPath(RUNTIME_CONFIG); export const CUED_SOCKET_PATH = join(CUED_HOME, "cued.sock"); export const CUED_DAEMON_LOCK_PATH = join(CUED_HOME, "daemon.lock"); export const CUED_MENU_BAR_LOCK_PATH = join(CUED_HOME, "menu-bar.lock"); @@ -46,70 +140,198 @@ export const CUED_UPDATE_DOWNLOADS_DIR = join(CUED_UPDATES_DIR, "downloads"); export const CUED_UPDATE_ROLLBACK_DIR = join(CUED_UPDATES_DIR, "rollback"); export const CUED_BACKUPS_DIR = join(CUED_HOME, "backups"); -export function ensureCuedDirs(): void { - if (!existsSync(CUED_HOME)) { - mkdirSync(CUED_HOME, { recursive: true, mode: 0o700 }); - } - - if (!existsSync(CUED_LOG_DIR)) { - mkdirSync(CUED_LOG_DIR, { recursive: true, mode: 0o700 }); - } - - if (!existsSync(CUED_BROWSER_DIR)) { - mkdirSync(CUED_BROWSER_DIR, { recursive: true, mode: 0o700 }); - } - - if (!existsSync(CUED_INTEGRATIONS_DIR)) { - mkdirSync(CUED_INTEGRATIONS_DIR, { recursive: true, mode: 0o700 }); - } +const PLATFORM_AUTO_SYNC_INTERVAL_MS: Partial> = { + discord: 10 * 60_000, + signal: 300_000, + whatsapp: 300_000, +}; - if (!existsSync(CUED_SIGNAL_DIR)) { - mkdirSync(CUED_SIGNAL_DIR, { recursive: true, mode: 0o700 }); - } +export const CONFIG = { + app: { + repoRoot: REPO_ROOT, + }, + macOS: { + currentAppPath: null as string | null, + }, + nativeRuntime: { + repoRoot: REPO_ROOT, + }, + platformRuntime: { + signalConfigRoot: CUED_SIGNAL_DIR, + }, + daemon: { + autoSyncPlatforms: null as AdapterPlatform[] | null, + realtimePlatforms: null as AdapterPlatform[] | null, + autoSyncIntervalMs: 60_000, + platformAutoSyncIntervalMs: PLATFORM_AUTO_SYNC_INTERVAL_MS, + autoSyncSchedulerTickMs: 15_000, + ingestConcurrency: 4, + projectionBatchSize: 100, + projectionBacklogTiers: [ + { minEvents: 100_000, batchSize: 2_000, continueDelayMs: 0 }, + { minEvents: 25_000, batchSize: 1_500, continueDelayMs: 0 }, + { minEvents: 5_000, batchSize: 1_000, continueDelayMs: 0 }, + { minEvents: 1_000, batchSize: 500, continueDelayMs: null }, + ] satisfies ProjectionBacklogTier[], + projectionContinueDelayMs: 5_000, + projectionContinueDelayMsOverride: null as number | null, + messageFtsIndexBatchSize: 250, + realtimeProjectionEnabled: true, + realtimeProjectionBatchSize: null as number | null, + deferredProjectionCoalesceMs: 250, + autoSyncProjectionBacklogPauseEvents: 5_000, + syncContinueDelayMs: 15_000, + signalReconnectSyncCooldownMs: 5 * 60_000, + continuationProjectionIntervalMs: 2_000, + continuationProjectionBacklogEvents: 500, + inlineProjectionMaxRawEvents: 250, + interactiveAuth: { + ttlMs: 45_000, + projectionBatchSize: 25, + projectionContinueDelayMs: 5_000, + syncContinueDelayMs: 30_000, + }, + discordRealtime: { + enabled: true, + dmPollMs: 45_000, + }, + slackRealtime: { + enabled: false, + pollMs: undefined as number | undefined, + userRefreshMs: undefined as number | undefined, + conversationLimit: undefined as number | undefined, + messageLimit: undefined as number | undefined, + }, + whatsappResyncPageBudget: 10, + }, + telemetry: { + endpoint: "https://cued.so/api/telemetry/events", + }, + updater: { + repo: "Cue-d/cued", + apiBase: "https://api.github.com", + }, + contacts: { + jsonPath: null as string | null, + configPath: join(CUED_HOME, "contacts.config.json"), + }, + imessage: { + chatDbPath: join(homedir(), "Library", "Messages", "chat.db"), + callHistoryDbPath: join( + homedir(), + "Library/Application Support/CallHistoryDB/CallHistory.storedata", + ), + }, + gmailSync: { + pageSize: 50, + pageBudget: 5, + fetchConcurrency: 8, + }, + linkedinSync: { + connectionPages: 25, + conversationPages: 50, + messagePages: 10, + fetchConcurrency: 3, + }, + discordSync: { + messageChannelLimit: 5, + messagesPerChannelLimit: 50, + backfillPageLimit: 2, + }, + slackSync: { + apiPageBudget: 25, + }, + slackDesktopImport: { + appBinary: "/Applications/Slack.app/Contents/MacOS/Slack", + userDataDir: join(homedir(), "Library", "Application Support", "Slack"), + remoteDebuggingPort: 9222, + timeoutMs: 20_000, + }, + signalCli: { + repoRoot: REPO_ROOT, + }, + authRuntime: { + chromiumAuthTimeoutMs: 15 * 60_000, + chromiumExecutablePath: resolveChromiumExecutablePath(), + oauthTimeoutMs: 5 * 60 * 1000, + fakeAuthResultFile: resolve(CUED_HOME, "auth-test-hooks.json"), + fakeChromiumAuthResult: null as Record | null, + fakeQrNativeAuthResult: null as Record | null, + }, +}; - if (!existsSync(CUED_WHATSAPP_DIR)) { - mkdirSync(CUED_WHATSAPP_DIR, { recursive: true, mode: 0o700 }); - } +export const MACOS_APP_CONFIG = CONFIG.macOS; +export const NATIVE_RUNTIME_CONFIG = CONFIG.nativeRuntime; +export const PLATFORM_RUNTIME_CONFIG = CONFIG.platformRuntime; +export const DAEMON_CONFIG = CONFIG.daemon; +export const TELEMETRY_CONFIG = CONFIG.telemetry; +export const UPDATE_RELEASE_CONFIG = CONFIG.updater; +export const CONTACTS_CONFIG = CONFIG.contacts; +export const IMESSAGE_CONFIG = CONFIG.imessage; +export const GMAIL_SYNC_CONFIG = CONFIG.gmailSync; +export const LINKEDIN_SYNC_CONFIG = CONFIG.linkedinSync; +export const DISCORD_SYNC_CONFIG = CONFIG.discordSync; +export const SLACK_SYNC_CONFIG = CONFIG.slackSync; +export const SLACK_DESKTOP_IMPORT_CONFIG = CONFIG.slackDesktopImport; +export const SIGNAL_CLI_CONFIG = CONFIG.signalCli; +export const AUTH_RUNTIME_CONFIG = CONFIG.authRuntime; - if (!existsSync(CUED_ATTACHMENTS_DIR)) { - mkdirSync(CUED_ATTACHMENTS_DIR, { recursive: true, mode: 0o700 }); +export function getConfiguredContactsJsonPath(): string | null { + if (CONTACTS_CONFIG.jsonPath) { + return CONTACTS_CONFIG.jsonPath; } - - if (!existsSync(CUED_ATTACHMENTS_OBJECTS_DIR)) { - mkdirSync(CUED_ATTACHMENTS_OBJECTS_DIR, { recursive: true, mode: 0o700 }); + if (!existsSync(CONTACTS_CONFIG.configPath)) { + return null; } - if (!existsSync(CUED_ATTACHMENTS_TMP_DIR)) { - mkdirSync(CUED_ATTACHMENTS_TMP_DIR, { recursive: true, mode: 0o700 }); - } + const parsed = JSON.parse(readFileSync(CONTACTS_CONFIG.configPath, "utf8")) as { + jsonPath?: unknown; + }; + return typeof parsed.jsonPath === "string" && parsed.jsonPath.trim() ? parsed.jsonPath : null; +} - if (!existsSync(CUED_UPDATES_DIR)) { - mkdirSync(CUED_UPDATES_DIR, { recursive: true, mode: 0o700 }); +export function readFakeAuthResult(runtime: FakeAuthRuntime): Record | null { + const configured = + runtime === "chromium" + ? AUTH_RUNTIME_CONFIG.fakeChromiumAuthResult + : AUTH_RUNTIME_CONFIG.fakeQrNativeAuthResult; + if (configured) { + return configured; } - if (!existsSync(CUED_UPDATE_DOWNLOADS_DIR)) { - mkdirSync(CUED_UPDATE_DOWNLOADS_DIR, { recursive: true, mode: 0o700 }); + try { + const parsed = JSON.parse(readFileSync(AUTH_RUNTIME_CONFIG.fakeAuthResultFile, "utf8")) as { + chromium?: unknown; + qrNative?: unknown; + }; + const value = runtime === "chromium" ? parsed.chromium : parsed.qrNative; + return value && typeof value === "object" ? (value as Record) : null; + } catch { + return null; } +} - if (!existsSync(CUED_UPDATE_ROLLBACK_DIR)) { - mkdirSync(CUED_UPDATE_ROLLBACK_DIR, { recursive: true, mode: 0o700 }); - } +export function ensureCuedDirs(): void { + const dirs = [ + CUED_HOME, + CUED_LOG_DIR, + CUED_BROWSER_DIR, + CUED_INTEGRATIONS_DIR, + CUED_SIGNAL_DIR, + CUED_WHATSAPP_DIR, + CUED_ATTACHMENTS_DIR, + CUED_ATTACHMENTS_OBJECTS_DIR, + CUED_ATTACHMENTS_TMP_DIR, + CUED_UPDATES_DIR, + CUED_UPDATE_DOWNLOADS_DIR, + CUED_UPDATE_ROLLBACK_DIR, + CUED_BACKUPS_DIR, + ]; - if (!existsSync(CUED_BACKUPS_DIR)) { - mkdirSync(CUED_BACKUPS_DIR, { recursive: true, mode: 0o700 }); + for (const dir of dirs) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + chmodSync(dir, 0o700); } - - // Best-effort hardening for the local data dir. - chmodSync(CUED_HOME, 0o700); - chmodSync(CUED_BROWSER_DIR, 0o700); - chmodSync(CUED_INTEGRATIONS_DIR, 0o700); - chmodSync(CUED_SIGNAL_DIR, 0o700); - chmodSync(CUED_WHATSAPP_DIR, 0o700); - chmodSync(CUED_ATTACHMENTS_DIR, 0o700); - chmodSync(CUED_ATTACHMENTS_OBJECTS_DIR, 0o700); - chmodSync(CUED_ATTACHMENTS_TMP_DIR, 0o700); - chmodSync(CUED_UPDATES_DIR, 0o700); - chmodSync(CUED_UPDATE_DOWNLOADS_DIR, 0o700); - chmodSync(CUED_UPDATE_ROLLBACK_DIR, 0o700); - chmodSync(CUED_BACKUPS_DIR, 0o700); } diff --git a/src/core/env-example.test.ts b/src/core/env-example.test.ts new file mode 100644 index 00000000..e778867f --- /dev/null +++ b/src/core/env-example.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +const PUBLIC_ENV_EXAMPLE_KEYS = [ + "CUED_DB_KEY", + "CUED_GOOGLE_OAUTH_CLIENT_FILE", + "GOOGLE_OAUTH_CLIENT_FILE", + "CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE", + "CUED_CODESIGN_IDENTITY", + "CUED_NOTARY_PROFILE", +] as const; + +function readEnvExampleKeys(): string[] { + const contents = readFileSync(join(process.cwd(), ".env.example"), "utf8"); + return contents + .split("\n") + .map((line) => line.match(/^([A-Z][A-Z0-9_]*)=/)?.[1]) + .filter((key): key is string => typeof key === "string"); +} + +describe("public env example", () => { + it("documents only credential-bearing public env values", () => { + expect(readEnvExampleKeys()).toEqual([...PUBLIC_ENV_EXAMPLE_KEYS]); + }); +}); diff --git a/src/core/env.test.ts b/src/core/env.test.ts new file mode 100644 index 00000000..c25b7d5b --- /dev/null +++ b/src/core/env.test.ts @@ -0,0 +1,44 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveCuedDotenvPath } from "./env.js"; + +describe("dotenv loading", () => { + const tempDirs: string[] = []; + + afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } + }); + + function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + it("only loads dotenv files from the Cued package root", () => { + const root = createTempDir("not-cued-env-"); + const nested = join(root, "nested"); + mkdirSync(nested); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "other-app" })); + writeFileSync(join(root, ".env"), "CUED_DB_KEY=wrong\n"); + + expect(resolveCuedDotenvPath(nested)).toBe(null); + }); + + it("resolves repo-local dotenv files from nested Cued paths", () => { + const root = createTempDir("cued-env-"); + const nested = join(root, "dist", "core"); + mkdirSync(nested, { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "@cued/app" })); + writeFileSync(join(root, ".env"), "CUED_DB_KEY=secret\n"); + + expect(resolveCuedDotenvPath(nested)).toBe(join(root, ".env")); + }); +}); diff --git a/src/core/env.ts b/src/core/env.ts new file mode 100644 index 00000000..9ecab6df --- /dev/null +++ b/src/core/env.ts @@ -0,0 +1,35 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { config as loadDotenv } from "dotenv"; + +export function resolveCuedDotenvPath( + startDir = dirname(fileURLToPath(import.meta.url)), +): string | null { + let cursor = startDir; + while (true) { + const packageJsonPath = join(cursor, "package.json"); + if (existsSync(packageJsonPath)) { + try { + const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }; + if (parsed.name === "@cued/app") { + const dotenvPath = join(cursor, ".env"); + return existsSync(dotenvPath) ? dotenvPath : null; + } + } catch { + return null; + } + } + + const parent = dirname(cursor); + if (parent === cursor) { + return null; + } + cursor = parent; + } +} + +const dotenvPath = resolveCuedDotenvPath(); +if (dotenvPath) { + loadDotenv({ path: dotenvPath }); +} diff --git a/src/core/logging.ts b/src/core/logging.ts index 9552001f..31d7fe92 100644 --- a/src/core/logging.ts +++ b/src/core/logging.ts @@ -10,7 +10,7 @@ export interface Logger { error(message: string, details?: unknown): void; } -const DEFAULT_MAX_LOG_BYTES = Number(process.env.CUED_LOG_MAX_BYTES ?? 1_048_576); +const DEFAULT_MAX_LOG_BYTES = 1_048_576; function nowIso(): string { return new Date().toISOString(); diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts index 839dc7e7..643d5d8e 100644 --- a/src/db/sqlite.ts +++ b/src/db/sqlite.ts @@ -1,3 +1,4 @@ +import "../core/env.js"; import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { chmodSync, existsSync } from "node:fs"; diff --git a/src/macos/install.test.ts b/src/macos/install.test.ts index 084ed564..6f229cb4 100644 --- a/src/macos/install.test.ts +++ b/src/macos/install.test.ts @@ -15,6 +15,7 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +import { MACOS_APP_CONFIG } from "../core/config.js"; import { disableLoginItem, enableLoginItem, @@ -27,11 +28,11 @@ import { describe("macOS app bundle resolution", () => { const tempDirs: string[] = []; const originalHome = process.env.HOME; - const originalAppPath = process.env.CUED_APP_PATH; + const originalCurrentAppPath = MACOS_APP_CONFIG.currentAppPath; afterEach(() => { process.env.HOME = originalHome; - process.env.CUED_APP_PATH = originalAppPath; + MACOS_APP_CONFIG.currentAppPath = originalCurrentAppPath; vi.clearAllMocks(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -97,7 +98,6 @@ describe("macOS app bundle resolution", () => { it("reports login item status alongside legacy launch agent state", () => { const homeDir = setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); - process.env.CUED_APP_PATH = appPath; const plistPath = join(homeDir, "Library", "LaunchAgents", "dev.cued.daemon.plist"); mkdirSync(join(plistPath, ".."), { recursive: true }); @@ -130,7 +130,6 @@ describe("macOS app bundle resolution", () => { it("uses the launchctl-reported plist path when the shell HOME differs", () => { setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); - process.env.CUED_APP_PATH = appPath; const actualHome = createTempDir("cued-actual-home-"); const actualPlistPath = join(actualHome, "Library", "LaunchAgents", "dev.cued.daemon.plist"); @@ -159,7 +158,6 @@ describe("macOS app bundle resolution", () => { it("migrates an existing legacy launch agent when enabling the login item", () => { const homeDir = setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); - process.env.CUED_APP_PATH = appPath; const plistPath = join(homeDir, "Library", "LaunchAgents", "dev.cued.daemon.plist"); mkdirSync(join(plistPath, ".."), { recursive: true }); @@ -203,7 +201,6 @@ describe("macOS app bundle resolution", () => { it("disables the native login item and removes any legacy plist", () => { const homeDir = setTempHome(); const appPath = createAppBundle(createTempDir("cued-valid-app-"), "so.cued.desktop"); - process.env.CUED_APP_PATH = appPath; const plistPath = join(homeDir, "Library", "LaunchAgents", "dev.cued.daemon.plist"); mkdirSync(join(plistPath, ".."), { recursive: true }); diff --git a/src/macos/install.ts b/src/macos/install.ts index 0a925832..ebe4402e 100644 --- a/src/macos/install.ts +++ b/src/macos/install.ts @@ -3,8 +3,9 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync import { homedir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { MACOS_APP_CONFIG } from "../core/config.js"; import { CUED_APP_BUNDLE_IDENTIFIER, CUED_LEGACY_LAUNCH_AGENT_LABELS } from "../core/identity.js"; -import { resolveMacOSNativeBinary } from "../runtime/native-binary.js"; +import { resolveMacOSAppExecutable } from "../runtime/native-binary.js"; import { terminateCompetingDaemons } from "./competing-daemons.js"; const APP_NAME = "Cued.app"; @@ -43,12 +44,16 @@ function repoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); } +function bundledAppPath(): string | null { + const candidate = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../.."); + return isValidCuedAppBundle(candidate) ? candidate : null; +} + function currentAppPath(): string | null { - const appPath = process.env.CUED_APP_PATH; - if (!appPath || !isValidCuedAppBundle(appPath)) { - return null; + if (MACOS_APP_CONFIG.currentAppPath && isValidCuedAppBundle(MACOS_APP_CONFIG.currentAppPath)) { + return MACOS_APP_CONFIG.currentAppPath; } - return appPath; + return bundledAppPath(); } export function getCurrentAppPath(): string | null { @@ -114,7 +119,7 @@ export function getFallbackInstallAppPath(): string { export function resolveInstalledAppPath(): string | null { const candidates = [ - process.env.CUED_APP_PATH, + currentAppPath(), getDefaultInstallAppPath(), getFallbackInstallAppPath(), getBuiltAppPath(), @@ -256,10 +261,7 @@ function resolveLoginItemBinary(appPath?: string): string { return appExecutablePath(preferredAppPath); } - const nativeBinary = resolveMacOSNativeBinary( - process.env.CUED_AUTH_NATIVE_BINARY ?? process.env.CUED_CONTACTS_NATIVE_BINARY, - repoRoot(), - ); + const nativeBinary = resolveMacOSAppExecutable(repoRoot()); if (!nativeBinary) { throw new Error("CuedNative binary not found; build native/macos/CuedNative first"); } diff --git a/src/platforms/contacts/sync.test.ts b/src/platforms/contacts/sync.test.ts index 1a95c7f2..2d81c2e7 100644 --- a/src/platforms/contacts/sync.test.ts +++ b/src/platforms/contacts/sync.test.ts @@ -2,12 +2,15 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { CONTACTS_CONFIG } from "../../core/config.js"; import { buildContactsSyncBundle, resolveContactsLoader } from "./sync.js"; describe("contacts worker loader resolution", () => { const tempDirs: string[] = []; + const originalJsonPath = CONTACTS_CONFIG.jsonPath; afterEach(() => { + CONTACTS_CONFIG.jsonPath = originalJsonPath; while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -24,15 +27,8 @@ describe("contacts worker loader resolution", () => { it("prefers file input when configured", () => { const repoRoot = createRepoRoot(); - expect( - resolveContactsLoader( - { - CUED_CONTACTS_JSON_PATH: "/tmp/contacts.json", - CUED_CONTACTS_NATIVE_BINARY: "/tmp/native-cued", - }, - repoRoot, - ), - ).toEqual({ + CONTACTS_CONFIG.jsonPath = "/tmp/contacts.json"; + expect(resolveContactsLoader(repoRoot)).toEqual({ kind: "file", path: "/tmp/contacts.json", }); @@ -55,7 +51,7 @@ describe("contacts worker loader resolution", () => { writeFileSync(releaseCandidate, "#!/bin/sh\nexit 0\n"); chmodSync(releaseCandidate, 0o755); - expect(resolveContactsLoader({}, repoRoot)).toEqual({ + expect(resolveContactsLoader(repoRoot)).toEqual({ kind: "native", path: releaseCandidate, }); @@ -63,7 +59,7 @@ describe("contacts worker loader resolution", () => { it("falls back to JXA when no file input or native binary exists", () => { const repoRoot = createRepoRoot(); - expect(resolveContactsLoader({}, repoRoot)).toEqual({ kind: "jxa" }); + expect(resolveContactsLoader(repoRoot)).toEqual({ kind: "jxa" }); }); it("accepts the cached contacts wrapper shape", () => { @@ -83,25 +79,16 @@ describe("contacts worker loader resolution", () => { }), ); - const originalPath = process.env.CUED_CONTACTS_JSON_PATH; - try { - process.env.CUED_CONTACTS_JSON_PATH = fixturePath; - const bundle = buildContactsSyncBundle(); - expect(bundle.rawEvents).toHaveLength(1); - expect(bundle.rawEvents[0]?.payload).toEqual( - expect.objectContaining({ - fields: expect.objectContaining({ - display_name: "Ava Chen", - company: "Cued", - }), + CONTACTS_CONFIG.jsonPath = fixturePath; + const bundle = buildContactsSyncBundle(); + expect(bundle.rawEvents).toHaveLength(1); + expect(bundle.rawEvents[0]?.payload).toEqual( + expect.objectContaining({ + fields: expect.objectContaining({ + display_name: "Ava Chen", + company: "Cued", }), - ); - } finally { - if (originalPath === undefined) { - delete process.env.CUED_CONTACTS_JSON_PATH; - } else { - process.env.CUED_CONTACTS_JSON_PATH = originalPath; - } - } + }), + ); }); }); diff --git a/src/platforms/contacts/sync.ts b/src/platforms/contacts/sync.ts index aeae46b1..6f608312 100644 --- a/src/platforms/contacts/sync.ts +++ b/src/platforms/contacts/sync.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; +import { getConfiguredContactsJsonPath } from "../../core/config.js"; import type { ContactObservationPayload } from "../../core/types/provider.js"; import { getMacOSNativeBinaryCandidates, @@ -122,15 +123,13 @@ function loadContactsFromMacOS(): ContactRecordInput[] { export const getNativeContactsBinaryCandidates = getMacOSNativeBinaryCandidates; -export function resolveContactsLoader( - env: NodeJS.ProcessEnv = process.env, - repoRoot?: string, -): ContactsLoader { - if (env.CUED_CONTACTS_JSON_PATH) { - return { kind: "file", path: env.CUED_CONTACTS_JSON_PATH }; +export function resolveContactsLoader(repoRoot?: string): ContactsLoader { + const jsonPath = getConfiguredContactsJsonPath(); + if (jsonPath) { + return { kind: "file", path: jsonPath }; } - const nativeBinary = resolveMacOSNativeBinary(env.CUED_CONTACTS_NATIVE_BINARY, repoRoot); + const nativeBinary = resolveMacOSNativeBinary(repoRoot); if (nativeBinary) { return { kind: "native", path: nativeBinary }; } diff --git a/src/platforms/core/auth/chromium-worker.ts b/src/platforms/core/auth/chromium-worker.ts index dd85d2db..0cc26ad0 100644 --- a/src/platforms/core/auth/chromium-worker.ts +++ b/src/platforms/core/auth/chromium-worker.ts @@ -1,6 +1,11 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { type BrowserContext, type Cookie, chromium, type Page, type Request } from "playwright"; +import { + AUTH_RUNTIME_CONFIG, + readFakeAuthResult, + stripRuntimeConfigArgs, +} from "../../../core/config.js"; import { cuedAuthKeychainService } from "../../../core/identity.js"; import { type ChromiumAuthFailureCode, @@ -131,12 +136,13 @@ function parseArgs(argv: string[]): WorkerArgs { } function getTimeoutMs(): number { - const configured = Number(process.env.CUED_CHROMIUM_AUTH_TIMEOUT_MS ?? 15 * 60_000); - return Number.isFinite(configured) && configured > 0 ? configured : 15 * 60_000; + return AUTH_RUNTIME_CONFIG.chromiumAuthTimeoutMs; } function getExecutablePath(): string | undefined { - return process.env.CUED_CHROMIUM_EXECUTABLE_PATH || undefined; + return existsSync(AUTH_RUNTIME_CONFIG.chromiumExecutablePath) + ? AUTH_RUNTIME_CONFIG.chromiumExecutablePath + : undefined; } function bringAuthBrowserToFront(): void { @@ -501,11 +507,10 @@ function listOpenPages(context: BrowserContext): Page[] { } function getFakeResult(args: WorkerArgs): WorkerResult | null { - const raw = process.env.CUED_FAKE_CHROMIUM_AUTH_RESULT; - if (!raw) { + const parsed = readFakeAuthResult("chromium"); + if (!parsed) { return null; } - const parsed = JSON.parse(raw) as Record; return { sessionId: args.sessionId, platform: args.platform, @@ -528,7 +533,7 @@ function getFakeResult(args: WorkerArgs): WorkerResult | null { } async function run(): Promise { - const args = parseArgs(process.argv.slice(2)); + const args = parseArgs(stripRuntimeConfigArgs(process.argv.slice(2))); const fake = getFakeResult(args); if (fake) { process.stdout.write(JSON.stringify(fake)); diff --git a/src/platforms/core/auth/chromium.ts b/src/platforms/core/auth/chromium.ts index 001b9f47..d78e9c40 100644 --- a/src/platforms/core/auth/chromium.ts +++ b/src/platforms/core/auth/chromium.ts @@ -1,6 +1,7 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { join } from "node:path"; +import { getRuntimeConfigArgs } from "../../../core/config.js"; import type { AuthSessionState, Platform } from "../../../core/types/provider.js"; import type { CuedDatabase } from "../../../db/database.js"; import type { AuthSessionSummary, IntegrationStateSummary } from "../state/types.js"; @@ -83,6 +84,7 @@ function buildWorkerArgs( profileDir, "--launch-target", integration.launchTarget ?? "", + ...getRuntimeConfigArgs(), ]; } diff --git a/src/platforms/core/auth/native.ts b/src/platforms/core/auth/native.ts index 6b410c25..76443b5c 100644 --- a/src/platforms/core/auth/native.ts +++ b/src/platforms/core/auth/native.ts @@ -1,7 +1,7 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process"; import type { AuthSessionState, Platform } from "../../../core/types/provider.js"; import type { CuedDatabase } from "../../../db/database.js"; -import { resolveMacOSNativeBinary } from "../../../runtime/native-binary.js"; +import { resolveMacOSAppExecutable } from "../../../runtime/native-binary.js"; import type { AuthSessionSummary } from "../state/types.js"; export interface NativeAuthResult { @@ -21,9 +21,7 @@ export interface NativeAuthHandle { } function resolveNativeAuthBinary(): string { - const binary = resolveMacOSNativeBinary( - process.env.CUED_AUTH_NATIVE_BINARY ?? process.env.CUED_CONTACTS_NATIVE_BINARY, - ); + const binary = resolveMacOSAppExecutable(); if (!binary) { throw new Error("CuedNative binary not found; build native/macos/CuedNative first"); } diff --git a/src/platforms/core/auth/oauth.ts b/src/platforms/core/auth/oauth.ts index fb8b6fb6..0e9409d9 100644 --- a/src/platforms/core/auth/oauth.ts +++ b/src/platforms/core/auth/oauth.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import type { AddressInfo, Socket } from "node:net"; import { pathToFileURL } from "node:url"; +import { AUTH_RUNTIME_CONFIG, getRuntimeConfigArgs } from "../../../core/config.js"; import type { AuthSessionState, Platform } from "../../../core/types/provider.js"; import type { CuedDatabase } from "../../../db/database.js"; import { GmailClient, writeGmailSecret } from "../../gmail/api/client.js"; @@ -16,8 +17,6 @@ import { } from "../../gmail/oauth/client.js"; import type { AuthSessionSummary, IntegrationStateSummary } from "../state/types.js"; -const DEFAULT_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; - export interface OAuthAuthResult { sessionId: string; platform: Platform; @@ -75,7 +74,7 @@ async function runGmailOAuthSession( const { config, filePath } = readGoogleOAuthClientConfig(); const { verifier, challenge } = createPkcePair(); const state = randomBytes(16).toString("hex"); - const timeoutMs = Number(process.env.CUED_OAUTH_TIMEOUT_MS ?? DEFAULT_OAUTH_TIMEOUT_MS); + const timeoutMs = AUTH_RUNTIME_CONFIG.oauthTimeoutMs; const codePromise = new Promise<{ code: string; redirectUri: string }>((resolve, reject) => { const sockets = new Set(); @@ -193,7 +192,12 @@ export function startOAuthAuthSession( ): OAuthAuthHandle { const child = spawn( process.execPath, - [...process.execArgv, import.meta.filename, JSON.stringify({ session, integration })], + [ + ...process.execArgv, + import.meta.filename, + JSON.stringify({ session, integration }), + ...getRuntimeConfigArgs(), + ], { stdio: ["ignore", "pipe", "pipe"] }, ); const stdoutChunks: Buffer[] = []; diff --git a/src/platforms/core/auth/qr-native.ts b/src/platforms/core/auth/qr-native.ts index 3066acf0..efe86b3b 100644 --- a/src/platforms/core/auth/qr-native.ts +++ b/src/platforms/core/auth/qr-native.ts @@ -1,8 +1,9 @@ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; +import { readFakeAuthResult } from "../../../core/config.js"; import type { AuthSessionState, Platform } from "../../../core/types/provider.js"; import type { CuedDatabase } from "../../../db/database.js"; -import { resolveMacOSNativeBinary } from "../../../runtime/native-binary.js"; +import { resolveMacOSAppExecutable } from "../../../runtime/native-binary.js"; import { getSignalConfigDir, inspectSignalCli, @@ -53,9 +54,7 @@ function keepSentinelUntilComplete( } function resolveNativeQrBinary(): string { - const binary = resolveMacOSNativeBinary( - process.env.CUED_AUTH_NATIVE_BINARY ?? process.env.CUED_CONTACTS_NATIVE_BINARY, - ); + const binary = resolveMacOSAppExecutable(); if (!binary) { throw new Error("CuedNative binary not found; build native/macos/CuedNative first"); } @@ -144,8 +143,7 @@ function buildWhatsAppFailedResult(input: { phase: string; }): QrNativeAuthResult { const messageByCode: Record = { - helper_missing: - "WhatsApp helper was not found. Build native/helpers/whatsapp-go first or set CUED_WHATSAPP_HELPER_BINARY.", + helper_missing: "WhatsApp helper was not found. Build native/helpers/whatsapp-go first.", helper_pair_failed: "WhatsApp QR pairing failed in the helper runtime.", native_qr_unavailable: "WhatsApp QR window could not be opened.", qr_cancelled: "WhatsApp QR pairing cancelled.", @@ -195,12 +193,11 @@ function parseFakeResult( session: AuthSessionSummary, integration: IntegrationStateSummary, ): QrNativeAuthResult | null { - const raw = process.env.CUED_FAKE_QR_AUTH_RESULT; - if (!raw) { + const parsed = readFakeAuthResult("qrNative"); + if (!parsed) { return null; } - const parsed = JSON.parse(raw) as Record; return { sessionId: session.id, platform: session.platform, @@ -485,7 +482,7 @@ export function startQrNativeAuthSession( }; } - const errorSummary = `QR auth runtime not implemented yet for ${integration.platform}; set CUED_FAKE_QR_AUTH_RESULT for tests`; + const errorSummary = `QR auth runtime not implemented yet for ${integration.platform}`; const child = spawn("sh", ["-lc", "exit 1"], { stdio: "ignore" }); return { child, diff --git a/src/platforms/core/invocation.test.ts b/src/platforms/core/invocation.test.ts index 773d77e4..395d5dbe 100644 --- a/src/platforms/core/invocation.test.ts +++ b/src/platforms/core/invocation.test.ts @@ -1,36 +1,34 @@ import { describe, expect, it } from "vitest"; import { - buildAdapterInvocationEnv, - readAdapterInvocationEnv, + buildAdapterInvocation, + readAdapterInvocation, selectAdapterInvocationProofs, } from "./invocation.js"; -describe("adapter invocation env", () => { - it("preserves legacy cursor env vars while adding generic cursor env", () => { - const env = buildAdapterInvocationEnv({ +describe("adapter invocation", () => { + it("builds a structured cursor payload for workers", () => { + const invocation = buildAdapterInvocation({ platform: "linkedin", + accountKey: "default", checkpointSourceCursorJson: JSON.stringify({ lastSyncAt: 123, syncToken: "sync-token", }), }); - expect(env).toEqual({ - CUED_SYNC_SOURCE_CURSOR: JSON.stringify({ + expect(invocation).toEqual({ + accountKey: "default", + sourceCursor: { lastSyncAt: 123, syncToken: "sync-token", - }), - CUED_LINKEDIN_SOURCE_CURSOR: JSON.stringify({ - lastSyncAt: 123, - syncToken: "sync-token", - }), - CUED_LINKEDIN_LAST_SYNC_AT: "123", - CUED_LINKEDIN_SYNC_TOKEN: "sync-token", + }, + lastSyncAt: 123, + syncToken: "sync-token", }); }); - it("serializes proof rows for generic and Discord legacy proof env", () => { - const env = buildAdapterInvocationEnv({ + it("serializes proof rows into the worker payload", () => { + const invocation = buildAdapterInvocation({ platform: "discord", proofs: [ { @@ -46,8 +44,7 @@ describe("adapter invocation env", () => { ], }); - const parsed = JSON.parse(env.CUED_SYNC_PROOFS ?? "[]"); - expect(parsed).toEqual([ + expect(invocation.syncProofs).toEqual([ { scopeKind: "conversation", scopeKey: "dm-1", @@ -59,16 +56,17 @@ describe("adapter invocation env", () => { lastObservedAt: 456, }, ]); - expect(env.CUED_DISCORD_SYNC_PROOFS).toBe(env.CUED_SYNC_PROOFS); }); - it("reads generic invocation env when platform legacy env is absent", () => { - expect( - readAdapterInvocationEnv("slack", { - CUED_SYNC_SOURCE_CURSOR: JSON.stringify({ lastSyncAt: 123 }), - CUED_SYNC_PROOFS: JSON.stringify([{ scopeKey: "C1", proofKind: "messages" }]), - }), - ).toEqual({ + it("reads invocation JSON from stdin", async () => { + await expect( + readAdapterInvocation([ + JSON.stringify({ + sourceCursor: { lastSyncAt: 123 }, + syncProofs: [{ scopeKey: "C1", proofKind: "messages" }], + }), + ]), + ).resolves.toEqual({ sourceCursor: { lastSyncAt: 123 }, syncProofs: [{ scopeKey: "C1", proofKind: "messages" }], }); diff --git a/src/platforms/core/invocation.ts b/src/platforms/core/invocation.ts index aa76df1b..ecee2d2d 100644 --- a/src/platforms/core/invocation.ts +++ b/src/platforms/core/invocation.ts @@ -1,6 +1,20 @@ import { safeParseJsonRecord } from "../../db/codecs.js"; import type { AdapterPlatform } from "./types.js"; +export type AdapterInvocation = { + accountKey?: string; + sourceCursor?: unknown; + syncProofs?: unknown; + lastSyncAt?: number; + syncToken?: string | null; + signalAccount?: string; + imessageLastRowId?: number; + whatsappSource?: "desktop_db"; + whatsappDesktopSourcePath?: string; + slackHelperPath?: string | null; + slackHelperApiUrl?: string; +}; + export type AdapterInvocationProofRow = { scope_kind: string; scope_key: string; @@ -12,49 +26,54 @@ export type AdapterInvocationProofRow = { last_observed_at: number; }; -export function buildAdapterInvocationEnv(input: { +export function buildAdapterInvocation(input: { platform: AdapterPlatform; + accountKey?: string; checkpointSourceCursorJson?: string | null; proofs?: AdapterInvocationProofRow[]; -}): Record { - const env: Record = {}; - const platformEnvPrefix = `CUED_${input.platform.toUpperCase()}_`; +}): AdapterInvocation { + const invocation: AdapterInvocation = {}; const sourceCursor = safeParseJsonRecord( input.checkpointSourceCursorJson ?? null, "sync_checkpoints.source_cursor_json", ); - if (input.checkpointSourceCursorJson) { - env.CUED_SYNC_SOURCE_CURSOR = input.checkpointSourceCursorJson; - env[`${platformEnvPrefix}SOURCE_CURSOR`] = input.checkpointSourceCursorJson; + if (input.accountKey) { + invocation.accountKey = input.accountKey; + } + if (sourceCursor) { + invocation.sourceCursor = sourceCursor; } if (input.proofs && input.proofs.length > 0) { - const proofsJson = JSON.stringify(input.proofs.map(serializeInvocationProof)); - env.CUED_SYNC_PROOFS = proofsJson; - if (input.platform === "discord") { - env.CUED_DISCORD_SYNC_PROOFS = proofsJson; - } + invocation.syncProofs = input.proofs.map(serializeInvocationProof); } if (input.platform === "imessage" && typeof sourceCursor?.rowId === "number") { - env.CUED_IMESSAGE_LAST_ROWID = String(sourceCursor.rowId); + invocation.imessageLastRowId = sourceCursor.rowId; } if (input.platform === "slack" && typeof sourceCursor?.lastSyncAt === "number") { - env.CUED_SLACK_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); + invocation.lastSyncAt = sourceCursor.lastSyncAt; } if (input.platform === "linkedin") { if (typeof sourceCursor?.lastSyncAt === "number") { - env.CUED_LINKEDIN_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); + invocation.lastSyncAt = sourceCursor.lastSyncAt; } if (typeof sourceCursor?.syncToken === "string" && sourceCursor.syncToken.length > 0) { - env.CUED_LINKEDIN_SYNC_TOKEN = sourceCursor.syncToken; + invocation.syncToken = sourceCursor.syncToken; } } if (input.platform === "signal" && typeof sourceCursor?.lastSyncAt === "number") { - env.CUED_SIGNAL_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); + invocation.lastSyncAt = sourceCursor.lastSyncAt; } - return env; + return invocation; +} + +export async function readAdapterInvocation( + input: AsyncIterable | Iterable = process.stdin, +): Promise { + const raw = await readText(input); + return raw.trim() ? (JSON.parse(raw) as AdapterInvocation) : {}; } export function selectAdapterInvocationProofs(input: { @@ -107,24 +126,6 @@ export function selectAdapterInvocationProofs(input: { return input.proofs.filter((proof) => proof.status === "running"); } -export function readAdapterInvocationEnv( - platform: AdapterPlatform, - env: NodeJS.ProcessEnv = process.env, -): { - sourceCursor?: unknown; - syncProofs?: unknown; -} { - const platformEnvPrefix = `CUED_${platform.toUpperCase()}_`; - return { - sourceCursor: parseOptionalJsonEnv( - env[`${platformEnvPrefix}SOURCE_CURSOR`] ?? env.CUED_SYNC_SOURCE_CURSOR, - ), - syncProofs: parseOptionalJsonEnv( - env[`${platformEnvPrefix}SYNC_PROOFS`] ?? env.CUED_SYNC_PROOFS, - ), - }; -} - function serializeInvocationProof(proof: AdapterInvocationProofRow): Record { return { scopeKind: proof.scope_kind, @@ -138,8 +139,14 @@ function serializeInvocationProof(proof: AdapterInvocationProofRow): Record | Iterable, +): Promise { + const chunks: Buffer[] = []; + for await (const chunk of input) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); } function getStringPath(value: unknown, path: string[]): string | null { diff --git a/src/platforms/core/runner.test.ts b/src/platforms/core/runner.test.ts index 6bbd8029..22457cd3 100644 --- a/src/platforms/core/runner.test.ts +++ b/src/platforms/core/runner.test.ts @@ -25,6 +25,9 @@ vi.mock("./registry.js", () => ({ import { runAdapter } from "./runner.js"; class MockChild extends EventEmitter { + stdin = { + end: vi.fn(), + }; stdout = new EventEmitter(); stderr = new EventEmitter(); kill = vi.fn(); @@ -54,7 +57,7 @@ describe("adapter runner", () => { const child = new MockChild(); spawnMock.mockReturnValue(child); - const promise = runAdapter("slack", "workspace-a"); + const promise = runAdapter("slack", { accountKey: "workspace-a" }); child.stdout.emit( "data", Buffer.from( @@ -75,7 +78,7 @@ describe("adapter runner", () => { const child = new MockChild(); spawnMock.mockReturnValue(child); - const promise = runAdapter("slack", "workspace-a"); + const promise = runAdapter("slack", { accountKey: "workspace-a" }); promise.catch(() => undefined); const assertion = expect(promise).rejects.toThrow( "Adapter worker timed out after 50ms for platform 'slack' account 'workspace-a'", @@ -109,7 +112,7 @@ describe("adapter runner", () => { const child = new MockChild(); spawnMock.mockReturnValue(child); - const promise = runAdapter("signal", "default"); + const promise = runAdapter("signal", { accountKey: "default" }); child.stdout.emit( "data", Buffer.from( @@ -133,8 +136,9 @@ describe("adapter runner", () => { [...process.execArgv, tsWorker], expect.objectContaining({ detached: true, - stdio: ["ignore", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe"], }), ); + expect(child.stdin.end).toHaveBeenCalledWith(JSON.stringify({ accountKey: "default" })); }); }); diff --git a/src/platforms/core/runner.ts b/src/platforms/core/runner.ts index 789ee0ed..7007b5a7 100644 --- a/src/platforms/core/runner.ts +++ b/src/platforms/core/runner.ts @@ -1,13 +1,15 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; +import { getRuntimeConfigArgs } from "../../core/config.js"; import type { AdapterPlatform } from "../../core/types/provider.js"; +import type { AdapterInvocation } from "./invocation.js"; import { getAdapterDefinition } from "./registry.js"; import type { AdapterWorkerOutput, SyncBundle } from "./sync.js"; export async function runAdapter( platform: AdapterPlatform, - accountKey?: string, - envOverrides?: Record, + invocation: AdapterInvocation = {}, + subprocessEnv?: Record, ): Promise { const definition = getAdapterDefinition(platform); if (!definition) { @@ -17,15 +19,19 @@ export async function runAdapter( const workerEntrypoint = resolveWorkerEntrypoint(definition.workerEntrypoint); return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [...process.execArgv, workerEntrypoint], { - stdio: ["ignore", "pipe", "pipe"], - detached: true, - env: { - ...process.env, - ...(accountKey ? { CUED_ACCOUNT_KEY: accountKey } : {}), - ...envOverrides, + const child = spawn( + process.execPath, + [...process.execArgv, workerEntrypoint, ...getRuntimeConfigArgs()], + { + stdio: ["pipe", "pipe", "pipe"], + detached: true, + env: { + ...process.env, + ...subprocessEnv, + }, }, - }); + ); + child.stdin?.end(JSON.stringify(invocation)); let stdout = ""; let stderr = ""; @@ -38,7 +44,7 @@ export async function runAdapter( killAdapterProcessTree(child.pid, () => child.kill("SIGKILL")); reject( new Error( - `Adapter worker timed out after ${definition.workerTimeoutMs}ms for platform '${platform}'${accountKey ? ` account '${accountKey}'` : ""}`, + `Adapter worker timed out after ${definition.workerTimeoutMs}ms for platform '${platform}'${invocation.accountKey ? ` account '${invocation.accountKey}'` : ""}`, ), ); }, definition.workerTimeoutMs); diff --git a/src/platforms/core/runtime-paths.ts b/src/platforms/core/runtime-paths.ts index b52a6200..6f97f62e 100644 --- a/src/platforms/core/runtime-paths.ts +++ b/src/platforms/core/runtime-paths.ts @@ -1,6 +1,6 @@ import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync } from "node:fs"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; -import { CUED_BROWSER_DIR, CUED_SIGNAL_DIR, CUED_WHATSAPP_DIR } from "../../core/config.js"; +import { CUED_BROWSER_DIR, CUED_WHATSAPP_DIR, PLATFORM_RUNTIME_CONFIG } from "../../core/config.js"; import { validateIntegrationAccountKey } from "./account-keys.js"; import type { Platform } from "./types.js"; @@ -52,7 +52,7 @@ export function getChromiumProfileDir(platform: Platform, accountKey: string): s } export function getSignalConfigRoot(): string { - return process.env.CUED_SIGNAL_DIR?.trim() || CUED_SIGNAL_DIR; + return PLATFORM_RUNTIME_CONFIG.signalConfigRoot; } export function getWhatsAppStoreRoot(): string { diff --git a/src/platforms/core/state/integration-state.test.ts b/src/platforms/core/state/integration-state.test.ts index c7d2a074..185bbc8a 100644 --- a/src/platforms/core/state/integration-state.test.ts +++ b/src/platforms/core/state/integration-state.test.ts @@ -2,6 +2,13 @@ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + IMESSAGE_CONFIG, + NATIVE_RUNTIME_CONFIG, + PLATFORM_RUNTIME_CONFIG, + SIGNAL_CLI_CONFIG, + SLACK_DESKTOP_IMPORT_CONFIG, +} from "../../../core/config.js"; import { resolveHostOS } from "../../../core/platform-capabilities.js"; import { CuedDatabase } from "../../../db/database.js"; import { openSqliteDatabase } from "../../../db/sqlite.js"; @@ -11,6 +18,8 @@ import { } from "../../discord/sync/events.js"; import { importLinkedInStoredAuth } from "../../linkedin/auth/keychain-import.js"; import { storeSlackSession } from "../../slack/auth/session-store.js"; +import { SLACK_HELPER_CONFIG } from "../../slack/helper/binary.js"; +import { WHATSAPP_HELPER_CONFIG } from "../../whatsapp/helper/pair.js"; import { startQrNativeAuthSession } from "../auth/qr-native.js"; import { refreshLocalIntegrationStates } from "./local-refresh.js"; import { @@ -32,19 +41,26 @@ import { describe("integration state management", () => { const tempDirs: string[] = []; const originalPath = process.env.PATH; + const originalIMessageDbPath = IMESSAGE_CONFIG.chatDbPath; + const originalNativeRepoRoot = NATIVE_RUNTIME_CONFIG.repoRoot; + const originalSignalRepoRoot = SIGNAL_CLI_CONFIG.repoRoot; + const originalSlackAppBinary = SLACK_DESKTOP_IMPORT_CONFIG.appBinary; + const originalSlackUserDataDir = SLACK_DESKTOP_IMPORT_CONFIG.userDataDir; + const originalSlackHelperBinary = SLACK_HELPER_CONFIG.helperBinary; + const originalSignalConfigRoot = PLATFORM_RUNTIME_CONFIG.signalConfigRoot; + const originalWhatsAppHelperBinary = WHATSAPP_HELPER_CONFIG.helperBinary; afterEach(() => { process.env.PATH = originalPath; - delete process.env.CUED_CONTACTS_NATIVE_BINARY; - delete process.env.CUED_IMESSAGE_DB_PATH; - delete process.env.CUED_SIGNAL_DIR; - delete process.env.CUED_SIGNAL_CLI_PATH; - delete process.env.CUED_SLACK_APP_BINARY; - delete process.env.CUED_SLACK_HELPER_BINARY; - delete process.env.CUED_AUTH_NATIVE_BINARY; - delete process.env.CUED_APP_PATH; - delete process.env.CUED_WHATSAPP_HELPER_BINARY; + SLACK_HELPER_CONFIG.helperBinary = originalSlackHelperBinary; + WHATSAPP_HELPER_CONFIG.helperBinary = originalWhatsAppHelperBinary; delete process.env.CUED_GOOGLE_OAUTH_CLIENT_FILE; + IMESSAGE_CONFIG.chatDbPath = originalIMessageDbPath; + NATIVE_RUNTIME_CONFIG.repoRoot = originalNativeRepoRoot; + SIGNAL_CLI_CONFIG.repoRoot = originalSignalRepoRoot; + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = originalSlackAppBinary; + SLACK_DESKTOP_IMPORT_CONFIG.userDataDir = originalSlackUserDataDir; + PLATFORM_RUNTIME_CONFIG.signalConfigRoot = originalSignalConfigRoot; while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -60,6 +76,24 @@ describe("integration state management", () => { return dir; } + function installNativeBinary(contents: string): string { + const repoRoot = createTempDir("cued-native-repo-"); + NATIVE_RUNTIME_CONFIG.repoRoot = repoRoot; + const nativeBinaryPath = join( + repoRoot, + "native", + "macos", + "CuedNative", + ".build", + "release", + "CuedNative", + ); + mkdirSync(join(nativeBinaryPath, ".."), { recursive: true }); + writeFileSync(nativeBinaryPath, contents); + chmodSync(nativeBinaryPath, 0o755); + return nativeBinaryPath; + } + function createDb(): CuedDatabase { const dir = createTempDir("cued-integrations-db-"); const db = new CuedDatabase(join(dir, "local.db")); @@ -78,21 +112,22 @@ describe("integration state management", () => { return (db as unknown as { sqlite: RawSql }).sqlite; } - function createPackagedSignalHelper(version = "0.12.9"): string { - process.env.CUED_SIGNAL_DIR = createTempDir("cued-signal-config-"); - const appPath = join(createTempDir("cued-app-"), "Cued.app"); + function installSignalHelper(version = "0.12.9"): void { + PLATFORM_RUNTIME_CONFIG.signalConfigRoot = createTempDir("cued-signal-config-"); + const repoRoot = createTempDir("cued-signal-repo-"); + SIGNAL_CLI_CONFIG.repoRoot = repoRoot; const helperPath = join( - appPath, - "Contents", - "Resources", + repoRoot, + "native", "helpers", "signal-cli", + ".build", + "cued-signal-cli", "cued-signal-cli", ); mkdirSync(join(helperPath, ".."), { recursive: true }); writeFileSync(helperPath, `#!/bin/sh\necho "signal-cli ${version}"\n`); chmodSync(helperPath, 0o755); - return appPath; } function createSlackHelper(version = "0.1.0"): string { @@ -184,19 +219,14 @@ process.exit(44); it("refreshes managed integrations and creates managed auth sessions for browser platforms", async () => { installSecurityTool({}); - const nativeBinaryDir = createTempDir("cued-native-binary-"); - const nativeBinaryPath = join(nativeBinaryDir, "CuedNative"); - writeFileSync( - nativeBinaryPath, + installNativeBinary( '#!/bin/sh\nif [ "$1" = "contacts" ] && [ "$2" = "status" ]; then\n echo \'{"status":"authorized"}\'\n exit 0\nfi\nexit 1\n', ); - chmodSync(nativeBinaryPath, 0o755); - process.env.CUED_CONTACTS_NATIVE_BINARY = nativeBinaryPath; - process.env.CUED_IMESSAGE_DB_PATH = join(createTempDir("cued-imessage-"), "missing.db"); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); - process.env.CUED_APP_PATH = createPackagedSignalHelper(); - process.env.CUED_WHATSAPP_HELPER_BINARY = join( + IMESSAGE_CONFIG.chatDbPath = join(createTempDir("cued-imessage-"), "missing.db"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); + installSignalHelper(); + WHATSAPP_HELPER_CONFIG.helperBinary = join( createTempDir("cued-no-whatsapp-helper-"), "cued-whatsapp-helper", ); @@ -314,7 +344,7 @@ process.exit(44); }); it("includes local native integrations in setup status before the first refresh", () => { - process.env.CUED_IMESSAGE_DB_PATH = join(createTempDir("cued-imessage-"), "missing.db"); + IMESSAGE_CONFIG.chatDbPath = join(createTempDir("cued-imessage-"), "missing.db"); const db = createDb(); @@ -335,16 +365,11 @@ process.exit(44); }); it("prefers live local permissions over stale setup integration rows", () => { - const nativeBinaryDir = createTempDir("cued-native-binary-"); - const nativeBinaryPath = join(nativeBinaryDir, "CuedNative"); - writeFileSync( - nativeBinaryPath, + installNativeBinary( '#!/bin/sh\nif [ "$1" = "contacts" ] && [ "$2" = "status" ]; then\n echo \'{"status":"authorized"}\'\n exit 0\nfi\nexit 1\n', ); - chmodSync(nativeBinaryPath, 0o755); - process.env.CUED_CONTACTS_NATIVE_BINARY = nativeBinaryPath; - process.env.CUED_IMESSAGE_DB_PATH = join(createTempDir("cued-imessage-"), "missing.db"); + IMESSAGE_CONFIG.chatDbPath = join(createTempDir("cued-imessage-"), "missing.db"); const db = createDb(); db.upsertIntegrationState({ @@ -657,18 +682,13 @@ process.exit(44); }); it("refreshes only local integrations when using the local refresh path", () => { - const nativeBinaryDir = createTempDir("cued-native-binary-"); - const nativeBinaryPath = join(nativeBinaryDir, "CuedNative"); - writeFileSync( - nativeBinaryPath, + installNativeBinary( '#!/bin/sh\nif [ "$1" = "contacts" ] && [ "$2" = "status" ]; then\n echo \'{"status":"authorized"}\'\n exit 0\nfi\nexit 1\n', ); - chmodSync(nativeBinaryPath, 0o755); - process.env.CUED_CONTACTS_NATIVE_BINARY = nativeBinaryPath; - process.env.CUED_IMESSAGE_DB_PATH = join(createTempDir("cued-imessage-"), "missing.db"); - process.env.CUED_APP_PATH = createPackagedSignalHelper(); - process.env.CUED_WHATSAPP_HELPER_BINARY = join( + IMESSAGE_CONFIG.chatDbPath = join(createTempDir("cued-imessage-"), "missing.db"); + installSignalHelper(); + WHATSAPP_HELPER_CONFIG.helperBinary = join( createTempDir("cued-no-whatsapp-helper-"), "cued-whatsapp-helper", ); @@ -702,7 +722,7 @@ process.exit(44); it("repairs stale linkedin sync capability on refresh", async () => { installSecurityTool({}); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); const db = createDb(); db.upsertIntegrationState({ @@ -782,7 +802,7 @@ process.exit(44); savedAt: 1234, }, }); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); const db = createDb(); await refreshManagedIntegrationStates(db); @@ -939,7 +959,7 @@ process.exit(44); it("repairs stale slack sync capability only when the helper is available", async () => { installSecurityTool({}); - process.env.CUED_SLACK_HELPER_BINARY = createSlackHelper(); + SLACK_HELPER_CONFIG.helperBinary = createSlackHelper(); const db = createDb(); db.upsertIntegrationState({ @@ -980,9 +1000,9 @@ process.exit(44); it("marks bundled QR helpers without linked devices as needing auth", async () => { installSecurityTool({}); - process.env.CUED_APP_PATH = createPackagedSignalHelper("0.14.1"); - process.env.CUED_WHATSAPP_HELPER_BINARY = createWhatsAppHelper({ authenticated: false }); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + installSignalHelper("0.14.1"); + WHATSAPP_HELPER_CONFIG.helperBinary = createWhatsAppHelper({ authenticated: false }); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); const db = createDb(); await refreshManagedIntegrationStates(db); @@ -1014,12 +1034,12 @@ process.exit(44); it("does not trust WhatsApp helper authentication without a durable account jid", async () => { installSecurityTool({}); - process.env.CUED_APP_PATH = createPackagedSignalHelper("0.14.1"); - process.env.CUED_WHATSAPP_HELPER_BINARY = createWhatsAppHelper({ + installSignalHelper("0.14.1"); + WHATSAPP_HELPER_CONFIG.helperBinary = createWhatsAppHelper({ authenticated: true, accountJid: null, }); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); const db = createDb(); db.upsertIntegrationState({ @@ -1201,8 +1221,8 @@ process.exit(44); it("removes a signal integration and its local config directory", () => { const db = createDb(); - process.env.CUED_SIGNAL_DIR = createTempDir("cued-signal-root-"); - const configDir = join(process.env.CUED_SIGNAL_DIR, "default"); + PLATFORM_RUNTIME_CONFIG.signalConfigRoot = createTempDir("cued-signal-root-"); + const configDir = join(PLATFORM_RUNTIME_CONFIG.signalConfigRoot, "default"); mkdirSync(configDir, { recursive: true }); db.upsertIntegrationState({ @@ -1249,15 +1269,15 @@ process.exit(44); it("does not resurrect a removed signal integration during managed helper refresh", async () => { installSecurityTool({}); - process.env.CUED_APP_PATH = createPackagedSignalHelper("0.14.1"); - process.env.CUED_WHATSAPP_HELPER_BINARY = join( + installSignalHelper("0.14.1"); + WHATSAPP_HELPER_CONFIG.helperBinary = join( createTempDir("cued-missing-whatsapp-helper-"), "cued-whatsapp-helper", ); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); const db = createDb(); - const signalRoot = process.env.CUED_SIGNAL_DIR!; + const signalRoot = PLATFORM_RUNTIME_CONFIG.signalConfigRoot; const configDir = join(signalRoot, "default"); mkdirSync(configDir, { recursive: true }); db.upsertIntegrationState({ @@ -1535,15 +1555,11 @@ process.exit(44); it("refreshes signal and whatsapp managed states for every persisted account", async () => { installSecurityTool({}); - process.env.CUED_SIGNAL_CLI_PATH = join( - createTempDir("cued-missing-signal-cli-"), - "signal-cli", - ); - process.env.CUED_WHATSAPP_HELPER_BINARY = join( + WHATSAPP_HELPER_CONFIG.helperBinary = join( createTempDir("cued-missing-whatsapp-helper-"), "cued-whatsapp-helper", ); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); const db = createDb(); db.upsertIntegrationState({ @@ -1807,12 +1823,9 @@ exit 1 `, ); chmodSync(helperPath, 0o755); - process.env.CUED_WHATSAPP_HELPER_BINARY = helperPath; + WHATSAPP_HELPER_CONFIG.helperBinary = helperPath; - const nativePath = join(createTempDir("cued-native-qr-bin-"), "CuedNative"); - writeFileSync(nativePath, "#!/bin/sh\nsleep 30\n"); - chmodSync(nativePath, 0o755); - process.env.CUED_AUTH_NATIVE_BINARY = nativePath; + installNativeBinary("#!/bin/sh\nsleep 30\n"); const db = createDb(); const requested = requestIntegrationAccess(db, "whatsapp"); @@ -1833,7 +1846,7 @@ exit 1 }); it("returns privacy-safe WhatsApp QR missing helper failures", async () => { - process.env.CUED_WHATSAPP_HELPER_BINARY = join( + WHATSAPP_HELPER_CONFIG.helperBinary = join( createTempDir("cued-missing-whatsapp-helper-"), "cued-whatsapp-helper", ); @@ -1847,8 +1860,7 @@ exit 1 platform: "whatsapp", accountKey: "default", state: "failed", - errorSummary: - "WhatsApp helper was not found. Build native/helpers/whatsapp-go first or set CUED_WHATSAPP_HELPER_BINARY.", + errorSummary: "WhatsApp helper was not found. Build native/helpers/whatsapp-go first.", resultSummary: { runtime: "qr_native", helper: "cued-whatsapp-helper", @@ -1884,7 +1896,7 @@ exit 1 `, ); chmodSync(helperPath, 0o755); - process.env.CUED_WHATSAPP_HELPER_BINARY = helperPath; + WHATSAPP_HELPER_CONFIG.helperBinary = helperPath; const db = createDb(); const requested = requestIntegrationAccess(db, "whatsapp"); @@ -1933,12 +1945,9 @@ exit 1 `, ); chmodSync(helperPath, 0o755); - process.env.CUED_WHATSAPP_HELPER_BINARY = helperPath; + WHATSAPP_HELPER_CONFIG.helperBinary = helperPath; - const nativePath = join(createTempDir("cued-native-qr-bin-"), "CuedNative"); - writeFileSync(nativePath, "#!/bin/sh\nexit 0\n"); - chmodSync(nativePath, 0o755); - process.env.CUED_AUTH_NATIVE_BINARY = nativePath; + installNativeBinary("#!/bin/sh\nexit 0\n"); const db = createDb(); const requested = requestIntegrationAccess(db, "whatsapp"); @@ -1985,11 +1994,8 @@ exit 1 `, ); chmodSync(helperPath, 0o755); - process.env.CUED_WHATSAPP_HELPER_BINARY = helperPath; - process.env.CUED_AUTH_NATIVE_BINARY = join( - createTempDir("cued-missing-native-qr-bin-"), - "CuedNative", - ); + WHATSAPP_HELPER_CONFIG.helperBinary = helperPath; + NATIVE_RUNTIME_CONFIG.repoRoot = createTempDir("cued-missing-native-repo-"); const db = createDb(); const requested = requestIntegrationAccess(db, "whatsapp"); @@ -2007,7 +2013,7 @@ exit 1 helperAvailable: true, helperVersion: "0.1.0", errorCode: "native_qr_unavailable", - phase: "pairing", + phase: "start_pairing", }, }); expect(JSON.stringify(result)).not.toMatch(/whatsapp-test-qr|storeDir|helperPath/); diff --git a/src/platforms/core/state/local.ts b/src/platforms/core/state/local.ts index bb2500b1..7eecc43e 100644 --- a/src/platforms/core/state/local.ts +++ b/src/platforms/core/state/local.ts @@ -1,16 +1,15 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { IMESSAGE_CONFIG } from "../../../core/config.js"; import { resolveMacOSNativeBinary } from "../../../runtime/native-binary.js"; -import { DEFAULT_CHAT_DB_PATH, IMessageReader } from "../../imessage/reader.js"; +import { IMessageReader } from "../../imessage/reader.js"; import { type IntegrationAuthState, parseIntegrationAuthState } from "../types.js"; import type { ManagedIntegrationState } from "./types.js"; export function getContactsAuthState( - resolveNativeBinary: ( - envVarValue: string | undefined, - ) => string | null = resolveMacOSNativeBinary, + resolveNativeBinary: () => string | null = resolveMacOSNativeBinary, ): IntegrationAuthState { - const nativeBinary = resolveNativeBinary(process.env.CUED_CONTACTS_NATIVE_BINARY); + const nativeBinary = resolveNativeBinary(); if (!nativeBinary) { return "native_helper_missing"; } @@ -27,15 +26,13 @@ export function getContactsAuthState( } export function getIMessageAuthState( - resolveNativeBinary: ( - envVarValue: string | undefined, - ) => string | null = resolveMacOSNativeBinary, + resolveNativeBinary: () => string | null = resolveMacOSNativeBinary, ): IntegrationAuthState { - const chatDbPath = process.env.CUED_IMESSAGE_DB_PATH ?? DEFAULT_CHAT_DB_PATH; + const chatDbPath = IMESSAGE_CONFIG.chatDbPath; if (!existsSync(chatDbPath)) { return "missing"; } - const nativeBinary = resolveNativeBinary(process.env.CUED_IMESSAGE_NATIVE_BINARY); + const nativeBinary = resolveNativeBinary(); if (nativeBinary) { try { execFileSync( @@ -79,7 +76,7 @@ export function getIMessageAuthState( } export function buildLocalIntegrationStates(): ManagedIntegrationState[] { - const chatDbPath = process.env.CUED_IMESSAGE_DB_PATH ?? DEFAULT_CHAT_DB_PATH; + const chatDbPath = IMESSAGE_CONFIG.chatDbPath; const contactsAuthState = getContactsAuthState(); const imessageAuthState = getIMessageAuthState(); return [ diff --git a/src/platforms/core/state/slack-desktop-import-removal.test.ts b/src/platforms/core/state/slack-desktop-import-removal.test.ts index 8e8443c6..d09c4c59 100644 --- a/src/platforms/core/state/slack-desktop-import-removal.test.ts +++ b/src/platforms/core/state/slack-desktop-import-removal.test.ts @@ -25,18 +25,19 @@ vi.mock("playwright", () => ({ }, })); +import { SLACK_DESKTOP_IMPORT_CONFIG } from "../../../core/config.js"; import { importSlackDesktopAuth } from "../../slack/auth/desktop-import.js"; describe("slack desktop import removal tombstones", () => { const tempDirs: string[] = []; - const originalSlackAppBinary = process.env.CUED_SLACK_APP_BINARY; - const originalSlackUserDataDir = process.env.CUED_SLACK_USER_DATA_DIR; - const originalSlackDesktopImportTimeout = process.env.CUED_SLACK_DESKTOP_IMPORT_TIMEOUT_MS; + const originalSlackAppBinary = SLACK_DESKTOP_IMPORT_CONFIG.appBinary; + const originalSlackUserDataDir = SLACK_DESKTOP_IMPORT_CONFIG.userDataDir; + const originalSlackDesktopImportTimeout = SLACK_DESKTOP_IMPORT_CONFIG.timeoutMs; afterEach(() => { - restoreEnv("CUED_SLACK_APP_BINARY", originalSlackAppBinary); - restoreEnv("CUED_SLACK_USER_DATA_DIR", originalSlackUserDataDir); - restoreEnv("CUED_SLACK_DESKTOP_IMPORT_TIMEOUT_MS", originalSlackDesktopImportTimeout); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = originalSlackAppBinary; + SLACK_DESKTOP_IMPORT_CONFIG.userDataDir = originalSlackUserDataDir; + SLACK_DESKTOP_IMPORT_CONFIG.timeoutMs = originalSlackDesktopImportTimeout; vi.clearAllMocks(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -46,14 +47,6 @@ describe("slack desktop import removal tombstones", () => { } }); - function restoreEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; - } - function createTempDir(prefix: string): string { const dir = mkdtempSync(join(tmpdir(), prefix)); tempDirs.push(dir); @@ -72,8 +65,8 @@ describe("slack desktop import removal tombstones", () => { chmodSync(appBinary, 0o755); const userDataDir = createTempDir("cued-slack-user-data-"); mkdirSync(userDataDir, { recursive: true }); - process.env.CUED_SLACK_APP_BINARY = appBinary; - process.env.CUED_SLACK_USER_DATA_DIR = userDataDir; + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = appBinary; + SLACK_DESKTOP_IMPORT_CONFIG.userDataDir = userDataDir; } it("imports unrelated teams without resurrecting a removed workspace", async () => { @@ -143,13 +136,13 @@ describe("slack desktop import removal tombstones", () => { { platform: "slack", accountKey: "T_REMOVED", - sourcePath: process.env.CUED_SLACK_USER_DATA_DIR, + sourcePath: SLACK_DESKTOP_IMPORT_CONFIG.userDataDir, imported: false, }, { platform: "slack", accountKey: "T_ACTIVE", - sourcePath: process.env.CUED_SLACK_USER_DATA_DIR, + sourcePath: SLACK_DESKTOP_IMPORT_CONFIG.userDataDir, imported: true, }, ]); @@ -184,7 +177,7 @@ describe("slack desktop import removal tombstones", () => { it("relaunches Slack once when an existing desktop debugger is wedged", async () => { installFakeSlackDesktop(); - process.env.CUED_SLACK_DESKTOP_IMPORT_TIMEOUT_MS = "500"; + SLACK_DESKTOP_IMPORT_CONFIG.timeoutMs = 500; const debuggerReachable = [true, true, false, true]; execFileSyncMock.mockImplementation((command: string, args?: string[]) => { @@ -247,7 +240,7 @@ describe("slack desktop import removal tombstones", () => { { platform: "slack", accountKey: "T_RECOVERED", - sourcePath: process.env.CUED_SLACK_USER_DATA_DIR, + sourcePath: SLACK_DESKTOP_IMPORT_CONFIG.userDataDir, imported: true, }, ]); @@ -269,7 +262,7 @@ describe("slack desktop import removal tombstones", () => { it("does not reconnect when the stale Slack debugger keeps the port", async () => { installFakeSlackDesktop(); - process.env.CUED_SLACK_DESKTOP_IMPORT_TIMEOUT_MS = "500"; + SLACK_DESKTOP_IMPORT_CONFIG.timeoutMs = 500; execFileSyncMock.mockImplementation((command: string, args?: string[]) => { if (command === "curl") { diff --git a/src/platforms/discord/sync/bundle.test.ts b/src/platforms/discord/sync/bundle.test.ts index bebc321e..48099b10 100644 --- a/src/platforms/discord/sync/bundle.test.ts +++ b/src/platforms/discord/sync/bundle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import type { DiscordApiClient } from "../api/client.js"; import { buildDiscordSyncBundle, @@ -7,12 +7,6 @@ import { getDiscordSyncMessagesPerChannelLimit, } from "./bundle.js"; -const ORIGINAL_ENV = { ...process.env }; - -afterEach(() => { - process.env = { ...ORIGINAL_ENV }; -}); - describe("buildDiscordSyncBundle", () => { it("hydrates only the most recent DM messages during sync", async () => { const hydratedChannels: Array<{ channelId: string; limit?: number }> = []; @@ -898,17 +892,12 @@ describe("buildDiscordSyncBundle", () => { }); describe("discord sync limits", () => { - it("allows zero to disable channel hydration and historical backfill", () => { - process.env.CUED_DISCORD_SYNC_MESSAGE_CHANNEL_LIMIT = "0"; - process.env.CUED_DISCORD_SYNC_BACKFILL_PAGE_LIMIT = "0"; - - expect(getDiscordSyncMessageChannelLimit()).toBe(0); - expect(getDiscordSyncBackfillPageLimit()).toBe(0); + it("reads source-owned defaults instead of launch env", () => { + expect(getDiscordSyncMessageChannelLimit()).toBe(5); + expect(getDiscordSyncBackfillPageLimit()).toBe(2); }); - it("keeps per-channel message hydration positive", () => { - process.env.CUED_DISCORD_SYNC_MESSAGES_PER_CHANNEL_LIMIT = "0"; - + it("keeps per-channel message hydration in the sync config", () => { expect(getDiscordSyncMessagesPerChannelLimit()).toBe(50); }); }); diff --git a/src/platforms/discord/sync/bundle.ts b/src/platforms/discord/sync/bundle.ts index 0560c33c..f7aeaef9 100644 --- a/src/platforms/discord/sync/bundle.ts +++ b/src/platforms/discord/sync/bundle.ts @@ -1,3 +1,4 @@ +import { DISCORD_SYNC_CONFIG } from "../../../core/config.js"; import type { SourceAccountInput, SyncProofInput } from "../../../core/types/provider.js"; import { loadIntegrationSecret } from "../../core/secrets/keychain.js"; import type { SyncBundle } from "../../core/sync.js"; @@ -16,9 +17,6 @@ import { buildDiscordMessageEvent, } from "./events.js"; -const DEFAULT_SYNC_MESSAGE_CHANNEL_LIMIT = 5; -const DEFAULT_SYNC_MESSAGES_PER_CHANNEL_LIMIT = 50; -const DEFAULT_SYNC_BACKFILL_PAGE_LIMIT = 2; const DISCORD_INCREMENTAL_PAGE_LIMIT = 100; type DiscordHydrationDiagnostics = { @@ -119,20 +117,10 @@ export async function buildDiscordSyncBundle( sourceCursor?: unknown; } = {}, ): Promise { - const accountKey = input.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; + const accountKey = input.accountKey ?? "default"; const client = options.client ?? new DiscordApiClient(loadDiscordCredentials(accountKey)); - const sourceCursor = parseDiscordSyncCursor( - options.sourceCursor ?? - (typeof process.env.CUED_DISCORD_SOURCE_CURSOR === "string" - ? JSON.parse(process.env.CUED_DISCORD_SOURCE_CURSOR) - : null), - ); - const syncProofState = parseDiscordProofState( - options.syncProofs ?? - (typeof process.env.CUED_DISCORD_SYNC_PROOFS === "string" - ? JSON.parse(process.env.CUED_DISCORD_SYNC_PROOFS) - : null), - ); + const sourceCursor = parseDiscordSyncCursor(options.sourceCursor ?? null); + const syncProofState = parseDiscordProofState(options.syncProofs ?? null); const syncMessageChannelLimit = options.syncMessageChannelLimit ?? getDiscordSyncMessageChannelLimit(); const syncMessagesPerChannelLimit = @@ -691,40 +679,15 @@ function buildDiscordHydrationDiagnostics(input: { } export function getDiscordSyncMessageChannelLimit(): number { - return parseNonNegativeInteger( - process.env.CUED_DISCORD_SYNC_MESSAGE_CHANNEL_LIMIT, - DEFAULT_SYNC_MESSAGE_CHANNEL_LIMIT, - ); + return DISCORD_SYNC_CONFIG.messageChannelLimit; } export function getDiscordSyncMessagesPerChannelLimit(): number { - return parsePositiveInteger( - process.env.CUED_DISCORD_SYNC_MESSAGES_PER_CHANNEL_LIMIT, - DEFAULT_SYNC_MESSAGES_PER_CHANNEL_LIMIT, - ); + return DISCORD_SYNC_CONFIG.messagesPerChannelLimit; } export function getDiscordSyncBackfillPageLimit(): number { - return parseNonNegativeInteger( - process.env.CUED_DISCORD_SYNC_BACKFILL_PAGE_LIMIT, - DEFAULT_SYNC_BACKFILL_PAGE_LIMIT, - ); -} - -function parseNonNegativeInteger(value: string | undefined, fallback: number): number { - if (typeof value !== "string") { - return fallback; - } - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; -} - -function parsePositiveInteger(value: string | undefined, fallback: number): number { - if (typeof value !== "string") { - return fallback; - } - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + return DISCORD_SYNC_CONFIG.backfillPageLimit; } function selectChannelsForMessageHydration( diff --git a/src/platforms/discord/sync/worker.ts b/src/platforms/discord/sync/worker.ts index 193f9e4d..30b96e39 100644 --- a/src/platforms/discord/sync/worker.ts +++ b/src/platforms/discord/sync/worker.ts @@ -1,12 +1,12 @@ -import { readAdapterInvocationEnv } from "../../core/invocation.js"; +import { readAdapterInvocation } from "../../core/invocation.js"; import { buildDiscordSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("discord"); + const invocation = await readAdapterInvocation(); const bundle = await buildDiscordSyncBundle( { - accountKey: process.env.CUED_ACCOUNT_KEY, + accountKey: invocation.accountKey, }, { sourceCursor: invocation.sourceCursor, diff --git a/src/platforms/gmail/oauth/client.test.ts b/src/platforms/gmail/oauth/client.test.ts index 04a1f95a..88624490 100644 --- a/src/platforms/gmail/oauth/client.test.ts +++ b/src/platforms/gmail/oauth/client.test.ts @@ -50,7 +50,6 @@ describe("Gmail OAuth client config", () => { writeClient(explicitPath, "explicit-project"); writeClient(bundledPath, "bundled-project"); process.env.CUED_GOOGLE_OAUTH_CLIENT_FILE = explicitPath; - process.env.CUED_APP_PATH = join(dir, "Cued.app"); expect(resolveGoogleOAuthClientFile()).toBe(explicitPath); expect(readGoogleOAuthClientConfig().config.clientId).toBe( @@ -61,23 +60,15 @@ describe("Gmail OAuth client config", () => { } }); - it("falls back to the bundled app OAuth client when no user file exists", () => { + it("falls back to the bundled OAuth client when no user file exists", () => { const dir = mkdtempSync(join(tmpdir(), "cued-gmail-oauth-")); try { - const bundledPath = join( - dir, - "Cued.app", - "Contents", - "Resources", - "oauth", - "google-oauth-client.json", - ); + const bundledPath = join(dir, "oauth", "google-oauth-client.json"); writeClient(bundledPath, "bundled-project"); process.env.HOME = join(dir, "home"); - process.env.CUED_APP_PATH = join(dir, "Cued.app"); delete process.env.CUED_GOOGLE_OAUTH_CLIENT_FILE; delete process.env.GOOGLE_OAUTH_CLIENT_FILE; - delete process.env.CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE; + process.env.CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE = bundledPath; expect(resolveGoogleOAuthClientFile()).toBe(bundledPath); expect(readGoogleOAuthClientConfig().config.clientId).toBe( diff --git a/src/platforms/gmail/oauth/client.ts b/src/platforms/gmail/oauth/client.ts index d9d99d01..16122c4a 100644 --- a/src/platforms/gmail/oauth/client.ts +++ b/src/platforms/gmail/oauth/client.ts @@ -1,13 +1,12 @@ import { createHash, randomBytes } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { cuedAuthKeychainService } from "../../../core/identity.js"; export const GMAIL_KEYCHAIN_SERVICE = cuedAuthKeychainService("gmail"); export const GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; -export const GOOGLE_OAUTH_CLIENT_RESOURCE_PATH = - "Contents/Resources/oauth/google-oauth-client.json"; export interface GoogleOAuthClientConfig { clientId: string; @@ -45,21 +44,19 @@ export interface GoogleOAuthClientInspection { error: string | null; } +function runtimeBundledGoogleOAuthClientFile(): string { + return resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../../oauth/google-oauth-client.json", + ); +} + function resolveBundledGoogleOAuthClientFile(): string | null { const explicit = process.env.CUED_BUNDLED_GOOGLE_OAUTH_CLIENT_FILE; if (explicit) return explicit; - const appPath = process.env.CUED_APP_PATH; - if (appPath) { - const bundledPath = join(appPath, GOOGLE_OAUTH_CLIENT_RESOURCE_PATH); - if (existsSync(bundledPath)) return bundledPath; - } - - const runtimeRoot = process.env.CUED_BUNDLED_RUNTIME_ROOT; - if (runtimeRoot) { - const bundledPath = join(runtimeRoot, "..", "oauth", "google-oauth-client.json"); - if (existsSync(bundledPath)) return bundledPath; - } + const bundledPath = runtimeBundledGoogleOAuthClientFile(); + if (existsSync(bundledPath)) return bundledPath; return null; } diff --git a/src/platforms/gmail/sync/bundle.ts b/src/platforms/gmail/sync/bundle.ts index fffca936..7ce72305 100644 --- a/src/platforms/gmail/sync/bundle.ts +++ b/src/platforms/gmail/sync/bundle.ts @@ -1,3 +1,4 @@ +import { GMAIL_SYNC_CONFIG } from "../../../core/config.js"; import type { ProviderRawEventInput } from "../../../core/types/provider.js"; import { mapWithConcurrency } from "../../../core/utils/async.js"; import type { SyncBundle } from "../../core/sync.js"; @@ -9,10 +10,6 @@ import { } from "../api/client.js"; import { buildGmailRawEvents } from "./events.js"; -const DEFAULT_PAGE_SIZE = Number(process.env.CUED_GMAIL_PAGE_SIZE ?? "50"); -const DEFAULT_PAGE_BUDGET = Number(process.env.CUED_GMAIL_PAGE_BUDGET ?? "5"); -const DEFAULT_FETCH_CONCURRENCY = Number(process.env.CUED_GMAIL_FETCH_CONCURRENCY ?? "8"); - export interface GmailSourceCursor { emailAddress?: string; historyId?: string | null; @@ -101,11 +98,14 @@ export async function buildGmailSyncBundle( fetchConcurrency?: number; } = {}, ): Promise { - const accountKey = input.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; + const accountKey = input.accountKey ?? "default"; const cursor = parseCursor(input.sourceCursor); - const pageBudget = positiveInt(input.pageBudget ?? DEFAULT_PAGE_BUDGET, 5); - const pageSize = positiveInt(input.pageSize ?? DEFAULT_PAGE_SIZE, 50); - const fetchConcurrency = positiveInt(input.fetchConcurrency ?? DEFAULT_FETCH_CONCURRENCY, 8); + const pageBudget = positiveInt(input.pageBudget ?? GMAIL_SYNC_CONFIG.pageBudget, 5); + const pageSize = positiveInt(input.pageSize ?? GMAIL_SYNC_CONFIG.pageSize, 50); + const fetchConcurrency = positiveInt( + input.fetchConcurrency ?? GMAIL_SYNC_CONFIG.fetchConcurrency, + 8, + ); const observedAt = Date.now(); const startedAt = cursor.startedAt ?? observedAt; const client = GmailClient.fromKeychain(accountKey); diff --git a/src/platforms/gmail/sync/worker.ts b/src/platforms/gmail/sync/worker.ts index 641ef447..44026d27 100644 --- a/src/platforms/gmail/sync/worker.ts +++ b/src/platforms/gmail/sync/worker.ts @@ -1,24 +1,12 @@ -import { readAdapterInvocationEnv } from "../../core/invocation.js"; +import { readAdapterInvocation } from "../../core/invocation.js"; import { buildGmailSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("gmail"); - const pageBudget = process.env.CUED_GMAIL_PAGE_BUDGET - ? Number(process.env.CUED_GMAIL_PAGE_BUDGET) - : undefined; - const pageSize = process.env.CUED_GMAIL_PAGE_SIZE - ? Number(process.env.CUED_GMAIL_PAGE_SIZE) - : undefined; - const fetchConcurrency = process.env.CUED_GMAIL_FETCH_CONCURRENCY - ? Number(process.env.CUED_GMAIL_FETCH_CONCURRENCY) - : undefined; + const invocation = await readAdapterInvocation(); const bundle = await buildGmailSyncBundle({ - accountKey: process.env.CUED_ACCOUNT_KEY, + accountKey: invocation.accountKey, sourceCursor: invocation.sourceCursor, - pageBudget: Number.isFinite(pageBudget) ? pageBudget : undefined, - pageSize: Number.isFinite(pageSize) ? pageSize : undefined, - fetchConcurrency: Number.isFinite(fetchConcurrency) ? fetchConcurrency : undefined, }); process.stdout.write(JSON.stringify({ ok: true, bundle })); } catch (error) { diff --git a/src/platforms/imessage/sync.test.ts b/src/platforms/imessage/sync.test.ts index 1dd3adce..83abd5f2 100644 --- a/src/platforms/imessage/sync.test.ts +++ b/src/platforms/imessage/sync.test.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { IMESSAGE_CONFIG } from "../../core/config.js"; import { loadCallHistoryBatch } from "./call-history.js"; import { IMessageReader } from "./reader.js"; import { buildIMessageSyncBundle, resolveIMessageLoader } from "./sync.js"; @@ -12,8 +13,12 @@ const { DatabaseSync } = require("node:sqlite") as typeof import("node:sqlite"); describe("imessage worker loader resolution", () => { const tempDirs: string[] = []; + const originalChatDbPath = IMESSAGE_CONFIG.chatDbPath; + const originalCallHistoryDbPath = IMESSAGE_CONFIG.callHistoryDbPath; afterEach(() => { + IMESSAGE_CONFIG.chatDbPath = originalChatDbPath; + IMESSAGE_CONFIG.callHistoryDbPath = originalCallHistoryDbPath; while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -253,30 +258,31 @@ describe("imessage worker loader resolution", () => { return dbPath; } - it("prefers explicit native binary overrides", () => { - expect( - resolveIMessageLoader( - { - CUED_IMESSAGE_NATIVE_BINARY: "/tmp/CuedNative", - CUED_IMESSAGE_DB_PATH: "/tmp/chat.db", - }, - "/tmp/repo", - ), - ).toEqual({ + it("finds the compiled native binary when present", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "cued-imessage-native-")); + tempDirs.push(repoRoot); + const releasePath = join( + repoRoot, + "native", + "macos", + "CuedNative", + ".build", + "release", + "CuedNative", + ); + mkdirSync(join(releasePath, ".."), { recursive: true }); + writeFileSync(releasePath, "#!/bin/sh\nexit 0\n"); + chmodSync(releasePath, 0o755); + + expect(resolveIMessageLoader(repoRoot)).toEqual({ kind: "native", - path: "/tmp/CuedNative", + path: releasePath, }); }); it("falls back to the TypeScript reader path without a native binary", () => { - expect( - resolveIMessageLoader( - { - CUED_IMESSAGE_DB_PATH: "/tmp/chat.db", - }, - "/tmp/repo", - ), - ).toEqual({ + IMESSAGE_CONFIG.chatDbPath = "/tmp/chat.db"; + expect(resolveIMessageLoader("/tmp/repo")).toEqual({ kind: "ts", path: "/tmp/chat.db", }); @@ -286,13 +292,11 @@ describe("imessage worker loader resolution", () => { const chatDbPath = createSyntheticChatDb(650); const callHistoryPath = createSyntheticCallHistoryDb(); const repoRoot = createTempDir("cued-imessage-repo-"); - const env = { CUED_IMESSAGE_DB_PATH: chatDbPath }; const first = buildIMessageSyncBundle({ path: chatDbPath, callHistoryPath, limit: 500, - env, repoRoot, }); expect(first.hasMore).toBe(true); @@ -308,7 +312,6 @@ describe("imessage worker loader resolution", () => { callHistoryPath, lastRowId: 500, limit: 500, - env, repoRoot, }); expect(second.hasMore).toBe(false); @@ -320,13 +323,11 @@ describe("imessage worker loader resolution", () => { const chatDbPath = createSyntheticChatDb(650, { filteredRowIds: [500] }); const callHistoryPath = createSyntheticCallHistoryDb(); const repoRoot = createTempDir("cued-imessage-repo-"); - const env = { CUED_IMESSAGE_DB_PATH: chatDbPath }; const first = buildIMessageSyncBundle({ path: chatDbPath, callHistoryPath, limit: 500, - env, repoRoot, }); expect(first.rawEvents.some((event) => event.entityKind === "message")).toBe(true); @@ -338,7 +339,6 @@ describe("imessage worker loader resolution", () => { callHistoryPath, lastRowId: 500, limit: 500, - env, repoRoot, }); expect(second.hasMore).toBe(false); @@ -350,12 +350,10 @@ describe("imessage worker loader resolution", () => { const chatDbPath = createSyntheticChatDb(1, { attachmentRowIds: [1] }); const callHistoryPath = createSyntheticCallHistoryDb(); const repoRoot = createTempDir("cued-imessage-repo-"); - const env = { CUED_IMESSAGE_DB_PATH: chatDbPath }; const bundle = buildIMessageSyncBundle({ path: chatDbPath, callHistoryPath, - env, repoRoot, }); @@ -391,12 +389,10 @@ describe("imessage worker loader resolution", () => { }, ]); const repoRoot = createTempDir("cued-imessage-repo-"); - const env = { CUED_IMESSAGE_DB_PATH: chatDbPath }; const bundle = buildIMessageSyncBundle({ path: chatDbPath, callHistoryPath, - env, repoRoot, }); @@ -431,12 +427,10 @@ describe("imessage worker loader resolution", () => { }, ]); const repoRoot = createTempDir("cued-imessage-repo-"); - const env = { CUED_IMESSAGE_DB_PATH: chatDbPath }; const bundle = buildIMessageSyncBundle({ path: chatDbPath, callHistoryPath, - env, repoRoot, }); @@ -568,7 +562,6 @@ describe("imessage worker loader resolution", () => { const bundle = buildIMessageSyncBundle({ path: chatDbPath, callHistoryPath, - env: { CUED_IMESSAGE_DB_PATH: chatDbPath }, repoRoot: createTempDir("cued-imessage-repo-"), }); diff --git a/src/platforms/imessage/sync.ts b/src/platforms/imessage/sync.ts index 36f181d6..69232266 100644 --- a/src/platforms/imessage/sync.ts +++ b/src/platforms/imessage/sync.ts @@ -1,5 +1,6 @@ import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; +import { IMESSAGE_CONFIG } from "../../core/config.js"; import type { CallPayload, ContactObservationPayload, @@ -60,11 +61,8 @@ function buildCallConversationLabel(call: { type IMessageLoader = { kind: "native"; path: string } | { kind: "ts"; path: string }; -export function resolveIMessageLoader( - env: NodeJS.ProcessEnv = process.env, - repoRoot?: string, -): IMessageLoader { - const nativeBinary = resolveMacOSNativeBinary(env.CUED_IMESSAGE_NATIVE_BINARY, repoRoot); +export function resolveIMessageLoader(repoRoot?: string): IMessageLoader { + const nativeBinary = resolveMacOSNativeBinary(repoRoot); if (nativeBinary) { return { kind: "native", @@ -74,7 +72,7 @@ export function resolveIMessageLoader( return { kind: "ts", - path: env.CUED_IMESSAGE_DB_PATH || DEFAULT_CHAT_DB_PATH, + path: IMESSAGE_CONFIG.chatDbPath, }; } @@ -153,42 +151,36 @@ export function buildIMessageSyncBundle(options?: { sourceCursor?: unknown; limit?: number; callHistoryPath?: string; - env?: NodeJS.ProcessEnv; repoRoot?: string; }): SyncBundle { const limit = options?.limit ?? DEFAULT_IMESSAGE_BATCH_LIMIT; const cursor = resolveIMessageSourceCursor(options); - const env = options?.env ?? process.env; - const loader = resolveIMessageLoader(options?.env ?? process.env, options?.repoRoot); + const loader = resolveIMessageLoader(options?.repoRoot); + const chatDbPath = options?.path ?? IMESSAGE_CONFIG.chatDbPath; + const callHistoryPath = options?.callHistoryPath ?? IMESSAGE_CONFIG.callHistoryDbPath; const batch = loader.kind === "native" ? loadBatchFromNativeBinary(loader.path, { - path: options?.path, + path: chatDbPath, lastRowId: cursor.rowId, limit, }) : loadBatchFromTypeScript({ - path: options?.path, + path: chatDbPath, lastRowId: cursor.rowId, limit, }); const effectiveCallBatch = loader.kind === "native" ? loadCallBatchFromNativeBinary(loader.path, { - callHistoryPath: - options?.callHistoryPath ?? - env.CUED_CALL_HISTORY_DB_PATH ?? - DEFAULT_CALL_HISTORY_DB_PATH, - path: options?.path ?? env.CUED_IMESSAGE_DB_PATH ?? DEFAULT_CHAT_DB_PATH, + callHistoryPath, + path: chatDbPath, afterPk: cursor.callPk, limit, }) : loadCallHistoryBatch({ - path: - options?.callHistoryPath ?? - env.CUED_CALL_HISTORY_DB_PATH ?? - DEFAULT_CALL_HISTORY_DB_PATH, - chatDbPath: options?.path ?? env.CUED_IMESSAGE_DB_PATH ?? DEFAULT_CHAT_DB_PATH, + path: callHistoryPath, + chatDbPath, afterPk: cursor.callPk, limit, }); diff --git a/src/platforms/imessage/worker.ts b/src/platforms/imessage/worker.ts index f9de8612..0c15acb7 100644 --- a/src/platforms/imessage/worker.ts +++ b/src/platforms/imessage/worker.ts @@ -1,16 +1,14 @@ -import { readAdapterInvocationEnv } from "../core/invocation.js"; +import { readAdapterInvocation } from "../core/invocation.js"; import type { AdapterWorkerOutput } from "../core/sync.js"; import { buildIMessageSyncBundle, DEFAULT_IMESSAGE_BATCH_LIMIT } from "./sync.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("imessage"); + const invocation = await readAdapterInvocation(); const bundle = buildIMessageSyncBundle({ - path: process.env.CUED_IMESSAGE_DB_PATH || undefined, - lastRowId: Number(process.env.CUED_IMESSAGE_LAST_ROWID || "0"), + lastRowId: invocation.imessageLastRowId ?? 0, sourceCursor: invocation.sourceCursor, - limit: Number(process.env.CUED_IMESSAGE_BATCH_LIMIT || String(DEFAULT_IMESSAGE_BATCH_LIMIT)), - callHistoryPath: process.env.CUED_CALL_HISTORY_DB_PATH || undefined, + limit: DEFAULT_IMESSAGE_BATCH_LIMIT, }); const output: AdapterWorkerOutput = { ok: true, bundle }; process.stdout.write(JSON.stringify(output)); diff --git a/src/platforms/linkedin/sync/bundle.ts b/src/platforms/linkedin/sync/bundle.ts index 6ade2321..45c8e9b4 100644 --- a/src/platforms/linkedin/sync/bundle.ts +++ b/src/platforms/linkedin/sync/bundle.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { LINKEDIN_SYNC_CONFIG } from "../../../core/config.js"; import type { SourceAccountInput, SyncProofInput } from "../../../core/types/provider.js"; import { mapWithConcurrency } from "../../../core/utils/async.js"; import { openCuedDatabaseReadOnly } from "../../../db/database.js"; @@ -30,12 +31,6 @@ import { } from "./events.js"; const INCREMENTAL_BUFFER_MS = 5 * 60 * 1000; -const MAX_CONNECTION_PAGES = Number(process.env.CUED_LINKEDIN_CONNECTION_PAGES ?? "25"); -const MAX_CONVERSATION_PAGES = Number(process.env.CUED_LINKEDIN_CONVERSATION_PAGES ?? "50"); -const MAX_MESSAGE_PAGES = Number(process.env.CUED_LINKEDIN_MESSAGE_PAGES ?? "10"); -const DEFAULT_LINKEDIN_FETCH_CONCURRENCY = Number( - process.env.CUED_LINKEDIN_FETCH_CONCURRENCY ?? "3", -); type LinkedInClientLike = Pick< LinkedInClient, @@ -113,10 +108,7 @@ function stableId(seed: string): string { } function getLinkedInFetchConcurrency(): number { - return Number.isFinite(DEFAULT_LINKEDIN_FETCH_CONCURRENCY) && - DEFAULT_LINKEDIN_FETCH_CONCURRENCY > 0 - ? Math.trunc(DEFAULT_LINKEDIN_FETCH_CONCURRENCY) - : 3; + return LINKEDIN_SYNC_CONFIG.fetchConcurrency; } function incrementalOldestMs(lastSyncAt?: number): number { @@ -348,7 +340,9 @@ async function listConnections( const connections: Connection[] = []; let cursor: string | undefined; let pages = 0; - const maxPages = incremental ? Math.min(2, MAX_CONNECTION_PAGES) : MAX_CONNECTION_PAGES; + const maxPages = incremental + ? Math.min(2, LINKEDIN_SYNC_CONFIG.connectionPages) + : LINKEDIN_SYNC_CONFIG.connectionPages; do { const result = await client.getConnections(cursor); connections.push(...result.connections); @@ -419,7 +413,7 @@ async function listConversations( while ( firstPage.conversations.length > 0 && Number.isFinite(oldestLastActivity) && - pageCount < MAX_CONVERSATION_PAGES + pageCount < LINKEDIN_SYNC_CONFIG.conversationPages ) { const page = nextCursor && client.getConversationsWithCursor @@ -446,7 +440,7 @@ async function listConversations( } const complete = !( - pageCount >= MAX_CONVERSATION_PAGES && + pageCount >= LINKEDIN_SYNC_CONFIG.conversationPages && ((nextCursor && nextCursor.length > 0) || (Number.isFinite(oldestLastActivity) && oldestLastActivity !== Number.NEGATIVE_INFINITY)) ); @@ -532,7 +526,7 @@ async function listMessagesForConversation( while ( !incremental && - pageCount < MAX_MESSAGE_PAGES && + pageCount < LINKEDIN_SYNC_CONFIG.messagePages && ((prevCursor && prevCursor.length > 0) || (Number.isFinite(oldestDeliveredAt) && oldestDeliveredAt > oldestMs)) ) { @@ -847,7 +841,7 @@ export async function buildLinkedInSyncBundle(options?: { client?: LinkedInClientLike; loadProjectedReactions?: ProjectedReactionLookup; }): Promise { - const accountKey = options?.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; + const accountKey = options?.accountKey ?? "default"; const savedCursor = parseLinkedInSourceCursor(options?.sourceCursor); const syncProofState = parseLinkedInSyncProofState(options?.syncProofs, accountKey); const resumeScan = syncProofState.discoveryScan ?? savedCursor?.scan; diff --git a/src/platforms/linkedin/sync/worker.ts b/src/platforms/linkedin/sync/worker.ts index c4e28c96..8386f146 100644 --- a/src/platforms/linkedin/sync/worker.ts +++ b/src/platforms/linkedin/sync/worker.ts @@ -1,16 +1,13 @@ -import { readAdapterInvocationEnv } from "../../core/invocation.js"; +import { readAdapterInvocation } from "../../core/invocation.js"; import { buildLinkedInSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const lastSyncAt = process.env.CUED_LINKEDIN_LAST_SYNC_AT - ? Number(process.env.CUED_LINKEDIN_LAST_SYNC_AT) - : undefined; - const invocation = readAdapterInvocationEnv("linkedin"); + const invocation = await readAdapterInvocation(); const bundle = await buildLinkedInSyncBundle({ - accountKey: process.env.CUED_ACCOUNT_KEY, - lastSyncAt: Number.isFinite(lastSyncAt) ? lastSyncAt : undefined, - syncToken: process.env.CUED_LINKEDIN_SYNC_TOKEN ?? null, + accountKey: invocation.accountKey, + lastSyncAt: invocation.lastSyncAt, + syncToken: invocation.syncToken ?? null, sourceCursor: invocation.sourceCursor, syncProofs: invocation.syncProofs, }); diff --git a/src/platforms/signal/cli/client.test.ts b/src/platforms/signal/cli/client.test.ts index d3db68f7..99094756 100644 --- a/src/platforms/signal/cli/client.test.ts +++ b/src/platforms/signal/cli/client.test.ts @@ -63,7 +63,7 @@ describe("signal cli helpers", () => { expect(readSignalLinkedAccount(dir)).toBe("+14155550123"); }); - it("prefers the packaged app helper over the repo-local helper", () => { + it("ignores app path shaped helpers and uses source-owned candidates", () => { const repoRoot = createTempDir("cued-signal-repo-"); const appPath = join(createTempDir("cued-signal-app-"), "Cued.app"); const packagedHelper = join( @@ -89,11 +89,9 @@ describe("signal cli helpers", () => { createSignalHelperBinary(packagedHelper, "0.14.1"); createSignalHelperBinary(repoHelper, "0.13.1"); - const env = { CUED_APP_PATH: appPath } as NodeJS.ProcessEnv; - expect(getSignalCliBinaryCandidates(env, repoRoot)).toEqual( - expect.arrayContaining([packagedHelper, repoHelper]), - ); - expect(resolveSignalCliPath(env, repoRoot)).toBe(packagedHelper); + expect(getSignalCliBinaryCandidates(repoRoot)).toEqual(expect.arrayContaining([repoHelper])); + expect(getSignalCliBinaryCandidates(repoRoot)).not.toContain(packagedHelper); + expect(resolveSignalCliPath(repoRoot)).toBe(repoHelper); }); it("falls back to the repo-local staged helper when no packaged app helper exists", () => { @@ -111,21 +109,19 @@ describe("signal cli helpers", () => { mkdirSync(join(repoHelper, ".."), { recursive: true }); createSignalHelperBinary(repoHelper); - expect(resolveSignalCliPath({}, repoRoot)).toBe(repoHelper); + expect(resolveSignalCliPath(repoRoot)).toBe(repoHelper); }); - it("returns null when no bundled helper exists and ignores legacy env overrides", () => { + it("returns null when no bundled helper exists and ignores unrelated env overrides", () => { const repoRoot = createTempDir("cued-signal-repo-"); const legacyOverride = join(createTempDir("cued-signal-legacy-"), "signal-cli"); createSignalHelperBinary(legacyOverride); - expect( - resolveSignalCliPath({ CUED_SIGNAL_CLI_PATH: legacyOverride } as NodeJS.ProcessEnv, repoRoot), - ).toBeNull(); + expect(resolveSignalCliPath(repoRoot)).toBeNull(); }); it("uses the relocated runtime bundled helper fallback path", () => { - const candidates = getSignalCliHelperRootCandidates({}); + const candidates = getSignalCliHelperRootCandidates(); expect(candidates[0]).toBe( resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../helpers/signal-cli"), diff --git a/src/platforms/signal/cli/client.ts b/src/platforms/signal/cli/client.ts index 753e5521..ec1bd8c1 100644 --- a/src/platforms/signal/cli/client.ts +++ b/src/platforms/signal/cli/client.ts @@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { SIGNAL_CLI_CONFIG } from "../../../core/config.js"; import { validateIntegrationAccountKey } from "../../core/account-keys.js"; import { getSignalConfigRoot } from "../../core/runtime-paths.js"; @@ -111,10 +112,6 @@ function normalizeHandle(value: string): string { return value.trim().toLowerCase(); } -function resolveRepoRoot(): string { - return resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); -} - function makeSignalConfigDir(accountKey: string): string { return join(getSignalConfigRoot(), validateIntegrationAccountKey(accountKey)); } @@ -131,14 +128,8 @@ function runtimeBundledSignalCliRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../helpers/signal-cli"); } -export function getSignalCliHelperRootCandidates( - env: NodeJS.ProcessEnv = process.env, - repoRoot = resolveRepoRoot(), -): string[] { +export function getSignalCliHelperRootCandidates(repoRoot = SIGNAL_CLI_CONFIG.repoRoot): string[] { const candidates = [ - env.CUED_APP_PATH?.trim() - ? join(env.CUED_APP_PATH.trim(), "Contents", "Resources", "helpers", "signal-cli") - : null, runtimeBundledSignalCliRoot(), join(repoRoot, "native", "helpers", "signal-cli", ".build", "cued-signal-cli"), ].filter((value): value is string => Boolean(value)); @@ -146,11 +137,8 @@ export function getSignalCliHelperRootCandidates( return [...new Set(candidates)]; } -export function getSignalCliBinaryCandidates( - env: NodeJS.ProcessEnv = process.env, - repoRoot = resolveRepoRoot(), -): string[] { - return getSignalCliHelperRootCandidates(env, repoRoot).map((candidate) => +export function getSignalCliBinaryCandidates(repoRoot = SIGNAL_CLI_CONFIG.repoRoot): string[] { + return getSignalCliHelperRootCandidates(repoRoot).map((candidate) => join(candidate, SIGNAL_HELPER_BINARY_NAME), ); } @@ -167,11 +155,8 @@ function signalCliDistributionPathFromRoot(helperRoot: string): string { return join(helperRoot, "signal-cli"); } -export function resolveSignalCliPath( - env: NodeJS.ProcessEnv = process.env, - repoRoot = resolveRepoRoot(), -): string | null { - for (const candidate of getSignalCliBinaryCandidates(env, repoRoot)) { +export function resolveSignalCliPath(repoRoot = SIGNAL_CLI_CONFIG.repoRoot): string | null { + for (const candidate of getSignalCliBinaryCandidates(repoRoot)) { if (existsSync(candidate)) { return candidate; } @@ -206,14 +191,14 @@ export function isSignalCliVersionSupported(version: SignalCliVersion | null): b return version.patch >= MIN_SIGNAL_CLI_VERSION.patch; } -export async function inspectSignalCli(env: NodeJS.ProcessEnv = process.env): Promise<{ +export async function inspectSignalCli(): Promise<{ cliPath: string | null; helperRoot: string | null; distributionPath: string | null; javaHome: string | null; version: SignalCliVersion | null; }> { - const cliPath = resolveSignalCliPath(env); + const cliPath = resolveSignalCliPath(); if (!cliPath) { return { cliPath: null, diff --git a/src/platforms/signal/sync/bundle.ts b/src/platforms/signal/sync/bundle.ts index fb707b71..f0e175a8 100644 --- a/src/platforms/signal/sync/bundle.ts +++ b/src/platforms/signal/sync/bundle.ts @@ -16,10 +16,9 @@ export async function buildSignalSyncBundle(options?: { lastSyncAt?: number; client?: SignalClientLike; }): Promise { - const accountKey = options?.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; + const accountKey = options?.accountKey ?? "default"; const configDir = getSignalConfigDir(accountKey); - const account = - options?.account ?? process.env.CUED_SIGNAL_ACCOUNT ?? readSignalLinkedAccount(configDir); + const account = options?.account ?? readSignalLinkedAccount(configDir); if (!account) { throw new Error(`Signal account is not linked for '${accountKey}'`); } diff --git a/src/platforms/signal/sync/worker.ts b/src/platforms/signal/sync/worker.ts index 5202102b..f6cd74e8 100644 --- a/src/platforms/signal/sync/worker.ts +++ b/src/platforms/signal/sync/worker.ts @@ -1,14 +1,13 @@ +import { readAdapterInvocation } from "../../core/invocation.js"; import { buildSignalSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const lastSyncAt = process.env.CUED_SIGNAL_LAST_SYNC_AT - ? Number(process.env.CUED_SIGNAL_LAST_SYNC_AT) - : undefined; + const invocation = await readAdapterInvocation(); const bundle = await buildSignalSyncBundle({ - accountKey: process.env.CUED_ACCOUNT_KEY, - account: process.env.CUED_SIGNAL_ACCOUNT, - lastSyncAt: Number.isFinite(lastSyncAt) ? lastSyncAt : undefined, + accountKey: invocation.accountKey, + account: invocation.signalAccount, + lastSyncAt: invocation.lastSyncAt, }); process.stdout.write(JSON.stringify({ ok: true, bundle })); } catch (error) { diff --git a/src/platforms/slack/auth/desktop-import.ts b/src/platforms/slack/auth/desktop-import.ts index 55bd8b9a..559bb925 100644 --- a/src/platforms/slack/auth/desktop-import.ts +++ b/src/platforms/slack/auth/desktop-import.ts @@ -1,7 +1,7 @@ import { type ChildProcess, execFileSync, spawn } from "node:child_process"; import { existsSync } from "node:fs"; -import { join } from "node:path"; import { chromium } from "playwright"; +import { SLACK_DESKTOP_IMPORT_CONFIG } from "../../../core/config.js"; import type { CuedDatabase } from "../../../db/database.js"; import { storeSlackSession } from "./session-store.js"; @@ -9,16 +9,6 @@ declare const localStorage: { getItem(key: string): string | null; }; -const DEFAULT_SLACK_APP_BINARY = "/Applications/Slack.app/Contents/MacOS/Slack"; -const DEFAULT_SLACK_USER_DATA_DIR = join( - process.env.HOME ?? "", - "Library", - "Application Support", - "Slack", -); -const DEFAULT_DEBUGGING_PORT = 9222; -const DEFAULT_SLACK_DESKTOP_IMPORT_TIMEOUT_MS = 20_000; - interface SlackDesktopTeamConfig { id: string; name: string; @@ -31,25 +21,19 @@ interface SlackDesktopLocalConfig { } function getSlackAppBinary(): string { - return process.env.CUED_SLACK_APP_BINARY ?? DEFAULT_SLACK_APP_BINARY; + return SLACK_DESKTOP_IMPORT_CONFIG.appBinary; } function getSlackUserDataDir(): string { - return process.env.CUED_SLACK_USER_DATA_DIR ?? DEFAULT_SLACK_USER_DATA_DIR; + return SLACK_DESKTOP_IMPORT_CONFIG.userDataDir; } function getDebuggingPort(): number { - const configured = Number(process.env.CUED_SLACK_REMOTE_DEBUGGING_PORT ?? DEFAULT_DEBUGGING_PORT); - return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_DEBUGGING_PORT; + return SLACK_DESKTOP_IMPORT_CONFIG.remoteDebuggingPort; } function getDesktopImportTimeoutMs(): number { - const configured = Number( - process.env.CUED_SLACK_DESKTOP_IMPORT_TIMEOUT_MS ?? DEFAULT_SLACK_DESKTOP_IMPORT_TIMEOUT_MS, - ); - return Number.isFinite(configured) && configured > 0 - ? configured - : DEFAULT_SLACK_DESKTOP_IMPORT_TIMEOUT_MS; + return SLACK_DESKTOP_IMPORT_CONFIG.timeoutMs; } function hasAuthenticatedSlackIntegration(db: CuedDatabase): boolean { diff --git a/src/platforms/slack/e2e.test.ts b/src/platforms/slack/e2e.test.ts index 85f03bbc..10a25305 100644 --- a/src/platforms/slack/e2e.test.ts +++ b/src/platforms/slack/e2e.test.ts @@ -135,10 +135,14 @@ describe("slack e2e", () => { it("syncs full history, stays idempotent, and discovers new channels, DMs, and MPDMs", async () => { const envDir = mkdtempSync(join(tmpdir(), "cued-slack-e2e-")); tempDirs.push(envDir); + const configPath = join(envDir, "config.json"); + writeFileSync(configPath, JSON.stringify({ home: envDir, dbPath: join(envDir, "local.db") })); const db = new CuedDatabase(join(envDir, "local.db")); db.migrate(); const originalExecArgv = [...process.execArgv]; + const originalArgv = [...process.argv]; process.execArgv = ["--import", "tsx"]; + process.argv = [...originalArgv, "--config", configPath]; const securityDir = join(envDir, "bin"); const securityPath = join(securityDir, "security"); @@ -458,7 +462,6 @@ echo '{"token":"xoxc-test","cookie":"cookie-test","teamId":"T123","teamName":"Ac try { const first = await runSlackPhase(db, { - dbPath: db.dbPath, helperBinaryPath, apiURL, securityDir, @@ -537,7 +540,6 @@ echo '{"token":"xoxc-test","cookie":"cookie-test","teamId":"T123","teamName":"Ac phase = "rerun"; const second = await runSlackPhase(db, { - dbPath: db.dbPath, helperBinaryPath, apiURL, securityDir, @@ -553,7 +555,6 @@ echo '{"token":"xoxc-test","cookie":"cookie-test","teamId":"T123","teamName":"Ac phase = "expand"; const third = await runSlackPhase(db, { - dbPath: db.dbPath, helperBinaryPath, apiURL, securityDir, @@ -627,45 +628,42 @@ echo '{"token":"xoxc-test","cookie":"cookie-test","teamId":"T123","teamName":"Ac ); } finally { process.execArgv = originalExecArgv; + process.argv = originalArgv; db.close(); await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); } - }, 15_000); + }, 30_000); }); async function runSlackCycle( db: CuedDatabase, options: { - dbPath: string; helperBinaryPath: string; apiURL: string; securityDir: string; - apiPageBudget?: number; }, ) { const checkpoint = db.getCheckpoint("slack", "workspace-a"); const sourceCursor = checkpoint?.source_cursor_json ? JSON.parse(checkpoint.source_cursor_json) : null; - const envOverrides: Record = { - CUED_DB_PATH: options.dbPath, - CUED_SLACK_HELPER_BINARY: options.helperBinaryPath, - CUED_SLACK_HELPER_API_URL: options.apiURL, + const subprocessEnv: Record = { PATH: `${options.securityDir}:${process.env.PATH ?? ""}`, }; - if (typeof sourceCursor?.lastSyncAt === "number") { - envOverrides.CUED_SLACK_LAST_SYNC_AT = String(sourceCursor.lastSyncAt); - } - if (checkpoint?.source_cursor_json) { - envOverrides.CUED_SLACK_SOURCE_CURSOR = checkpoint.source_cursor_json; - } - if (typeof options.apiPageBudget === "number") { - envOverrides.CUED_SLACK_API_PAGE_BUDGET = String(options.apiPageBudget); - } - - const bundle = await runAdapter("slack", "workspace-a", envOverrides); + const bundle = await runAdapter( + "slack", + { + accountKey: "workspace-a", + slackHelperPath: options.helperBinaryPath, + slackHelperApiUrl: options.apiURL, + lastSyncAt: + typeof sourceCursor?.lastSyncAt === "number" ? sourceCursor.lastSyncAt : undefined, + sourceCursor: sourceCursor ?? undefined, + }, + subprocessEnv, + ); const insertResult = db.insertRawEvents(bundle.rawEvents); db.upsertSourceAccounts(bundle.sourceAccounts ?? []); for (const proof of bundle.proofs ?? []) { @@ -700,7 +698,6 @@ async function runSlackCycle( async function runSlackPhase( db: CuedDatabase, options: { - dbPath: string; helperBinaryPath: string; apiURL: string; securityDir: string; diff --git a/src/platforms/slack/helper/binary.test.ts b/src/platforms/slack/helper/binary.test.ts index 288238ba..0b038dfe 100644 --- a/src/platforms/slack/helper/binary.test.ts +++ b/src/platforms/slack/helper/binary.test.ts @@ -7,14 +7,15 @@ import { inspectSlackHelper, readSlackHelperStatus, resolveSlackHelperBinary, + SLACK_HELPER_CONFIG, } from "./binary.js"; describe("slack helper binary", () => { const tempDirs: string[] = []; + const originalHelperBinary = SLACK_HELPER_CONFIG.helperBinary; afterEach(() => { - delete process.env.CUED_APP_PATH; - delete process.env.CUED_SLACK_HELPER_BINARY; + SLACK_HELPER_CONFIG.helperBinary = originalHelperBinary; while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -66,7 +67,7 @@ exit 1 ); chmodSync(helperPath, 0o755); - process.env.CUED_SLACK_HELPER_BINARY = helperPath; + SLACK_HELPER_CONFIG.helperBinary = helperPath; expect(inspectSlackHelper()).toEqual({ helperPath, diff --git a/src/platforms/slack/helper/binary.ts b/src/platforms/slack/helper/binary.ts index 9135a076..5541565d 100644 --- a/src/platforms/slack/helper/binary.ts +++ b/src/platforms/slack/helper/binary.ts @@ -8,6 +8,10 @@ const execFileAsync = promisify(execFile); const SLACK_HELPER_BINARY_NAME = "cued-slack-helper"; const SUPPORTED_SLACK_HELPER_PROTOCOL_VERSION = 1; +export const SLACK_HELPER_CONFIG = { + helperBinary: null as string | null, +}; + function resolveRepoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); } @@ -19,14 +23,8 @@ function runtimeBundledSlackHelperBinary(): string { ); } -export function getSlackHelperBinaryCandidates( - env: NodeJS.ProcessEnv = process.env, - repoRoot = resolveRepoRoot(), -): string[] { +export function getSlackHelperBinaryCandidates(repoRoot = resolveRepoRoot()): string[] { return [ - env.CUED_APP_PATH?.trim() - ? join(env.CUED_APP_PATH.trim(), "Contents", "Resources", "helpers", SLACK_HELPER_BINARY_NAME) - : null, runtimeBundledSlackHelperBinary(), join(repoRoot, "native", "helpers", "slack-go", ".build", SLACK_HELPER_BINARY_NAME), join(repoRoot, "native", "helpers", "slack-go", SLACK_HELPER_BINARY_NAME), @@ -34,17 +32,15 @@ export function getSlackHelperBinaryCandidates( } export function resolveSlackHelperBinary( - envVarValue = process.env.CUED_SLACK_HELPER_BINARY, + explicitPath = SLACK_HELPER_CONFIG.helperBinary, repoRoot = resolveRepoRoot(), ): string | null { - if (envVarValue) { - return envVarValue; + if (explicitPath) { + return explicitPath; } return ( - getSlackHelperBinaryCandidates(process.env, repoRoot).find((candidate) => - existsSync(candidate), - ) ?? null + getSlackHelperBinaryCandidates(repoRoot).find((candidate) => existsSync(candidate)) ?? null ); } @@ -64,8 +60,7 @@ export function isSlackHelperProtocolSupported(protocolVersion: number | null): return protocolVersion === SUPPORTED_SLACK_HELPER_PROTOCOL_VERSION; } -export function inspectSlackHelper(): SlackHelperInspection { - const helperPath = resolveSlackHelperBinary(); +export function inspectSlackHelper(helperPath = resolveSlackHelperBinary()): SlackHelperInspection { if (!helperPath) { return { helperPath: null, @@ -100,8 +95,9 @@ export function inspectSlackHelper(): SlackHelperInspection { } } -export async function readSlackHelperStatus(): Promise { - const helperPath = resolveSlackHelperBinary(); +export async function readSlackHelperStatus( + helperPath = resolveSlackHelperBinary(), +): Promise { if (!helperPath) { return { helperVersion: null, diff --git a/src/platforms/slack/helper/client.ts b/src/platforms/slack/helper/client.ts index 023dd1a6..5896ae8e 100644 --- a/src/platforms/slack/helper/client.ts +++ b/src/platforms/slack/helper/client.ts @@ -50,6 +50,7 @@ export class SlackHelperError extends Error { export class SlackHelperClient implements SlackTransport { private readonly helperPath: string; + private readonly apiURL: string | null; private readonly spawnImpl: SpawnImpl; private readonly retryAttempts: number; private readonly retryBaseMs: number; @@ -58,6 +59,7 @@ export class SlackHelperClient implements SlackTransport { private readonly credentials: SlackCredentials, options: { helperPath?: string | null; + apiURL?: string | null; spawnImpl?: SpawnImpl; retryAttempts?: number; retryBaseMs?: number; @@ -69,6 +71,7 @@ export class SlackHelperClient implements SlackTransport { } this.helperPath = helperPath; + this.apiURL = options.apiURL?.trim() || null; this.spawnImpl = options.spawnImpl ?? spawn; this.retryAttempts = Math.max(1, options.retryAttempts ?? DEFAULT_RETRY_ATTEMPTS); this.retryBaseMs = Math.max(0, options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS); @@ -189,7 +192,8 @@ export class SlackHelperClient implements SlackTransport { | "getReplies", payload: Record, ): Promise { - const child = this.spawnImpl(this.helperPath, [command], { + const helperArgs = this.apiURL ? ["--api-url", this.apiURL, command] : [command]; + const child = this.spawnImpl(this.helperPath, helperArgs, { stdio: ["pipe", "pipe", "pipe"], env: process.env, }); diff --git a/src/platforms/slack/sync/bundle.ts b/src/platforms/slack/sync/bundle.ts index d45d36ce..9d23e090 100644 --- a/src/platforms/slack/sync/bundle.ts +++ b/src/platforms/slack/sync/bundle.ts @@ -1,3 +1,4 @@ +import { SLACK_SYNC_CONFIG } from "../../../core/config.js"; import type { SourceAccountInput } from "../../../core/types/provider.js"; import { loadIntegrationSecret } from "../../core/secrets/keychain.js"; import type { SyncBundle } from "../../core/sync.js"; @@ -22,7 +23,7 @@ const DEFAULT_SLACK_CONVERSATIONS_PER_RUN = 5; const DEFAULT_SLACK_MESSAGES_PAGE_LIMIT = 100; const DEFAULT_SLACK_REPLIES_PAGE_LIMIT = 50; const DEFAULT_SLACK_CHANNEL_HISTORY_DAYS = 0; -const DEFAULT_SLACK_API_PAGES_PER_RUN = 25; +const DEFAULT_SLACK_API_PAGES_PER_RUN = SLACK_SYNC_CONFIG.apiPageBudget; type SlackScanMode = "full" | "incremental"; type SlackConversationFamily = "direct" | "channels"; @@ -818,6 +819,8 @@ function buildCompleteSlackBackfillConversationProof(input: { export async function buildSlackSyncBundle(options?: { accountKey?: string; + helperPath?: string | null; + helperApiUrl?: string; lastSyncAt?: number; sourceCursor?: unknown; syncProofs?: unknown; @@ -826,9 +829,14 @@ export async function buildSlackSyncBundle(options?: { messagesPageLimit?: number; apiPageBudget?: number; }): Promise { - const accountKey = options?.accountKey ?? process.env.CUED_ACCOUNT_KEY ?? "default"; + const accountKey = options?.accountKey ?? "default"; const loadedAuth = options?.client ? null : loadSlackAuthFromKeychain(accountKey); - const client = options?.client ?? new SlackHelperClient(loadedAuth!); + const client = + options?.client ?? + new SlackHelperClient(loadedAuth!, { + helperPath: options?.helperPath, + apiURL: options?.helperApiUrl, + }); const savedCursor = parseSlackSourceCursor(options?.sourceCursor); const syncProofState = parseSlackSyncProofState(options?.syncProofs); const previousLastSyncAt = diff --git a/src/platforms/slack/sync/worker.ts b/src/platforms/slack/sync/worker.ts index 6383e002..93f591db 100644 --- a/src/platforms/slack/sync/worker.ts +++ b/src/platforms/slack/sync/worker.ts @@ -1,21 +1,16 @@ -import { readAdapterInvocationEnv } from "../../core/invocation.js"; +import { readAdapterInvocation } from "../../core/invocation.js"; import { buildSlackSyncBundle } from "./bundle.js"; async function main(): Promise { try { - const lastSyncAt = process.env.CUED_SLACK_LAST_SYNC_AT - ? Number(process.env.CUED_SLACK_LAST_SYNC_AT) - : undefined; - const invocation = readAdapterInvocationEnv("slack"); - const apiPageBudget = process.env.CUED_SLACK_API_PAGE_BUDGET - ? Number(process.env.CUED_SLACK_API_PAGE_BUDGET) - : undefined; + const invocation = await readAdapterInvocation(); const bundle = await buildSlackSyncBundle({ - accountKey: process.env.CUED_ACCOUNT_KEY, - lastSyncAt: Number.isFinite(lastSyncAt) ? lastSyncAt : undefined, + accountKey: invocation.accountKey, + helperPath: invocation.slackHelperPath, + helperApiUrl: invocation.slackHelperApiUrl, + lastSyncAt: invocation.lastSyncAt, sourceCursor: invocation.sourceCursor, syncProofs: invocation.syncProofs, - apiPageBudget: Number.isFinite(apiPageBudget) ? apiPageBudget : undefined, }); process.stdout.write(JSON.stringify({ ok: true, bundle })); } catch (error) { diff --git a/src/platforms/whatsapp/helper/pair.test.ts b/src/platforms/whatsapp/helper/pair.test.ts index 16ebe31a..03e4d52d 100644 --- a/src/platforms/whatsapp/helper/pair.test.ts +++ b/src/platforms/whatsapp/helper/pair.test.ts @@ -22,6 +22,7 @@ import { readWhatsAppHelperStatus, resolveWhatsAppHelperBinary, startWhatsAppPairSession, + WHATSAPP_HELPER_CONFIG, } from "./pair.js"; class MockPairChild extends EventEmitter { @@ -37,9 +38,10 @@ class MockPairChild extends EventEmitter { describe("whatsapp helper", () => { const tempDirs: string[] = []; + const originalHelperBinary = WHATSAPP_HELPER_CONFIG.helperBinary; afterEach(() => { - delete process.env.CUED_WHATSAPP_HELPER_BINARY; + WHATSAPP_HELPER_CONFIG.helperBinary = originalHelperBinary; vi.clearAllMocks(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -56,7 +58,7 @@ describe("whatsapp helper", () => { } it("uses the flattened repo root for implicit development candidates", () => { - expect(getWhatsAppHelperBinaryCandidates()[0]).toBe( + expect(getWhatsAppHelperBinaryCandidates()).toContain( join(process.cwd(), "native", "helpers", "whatsapp-go", ".build", "cued-whatsapp-helper"), ); }); @@ -69,20 +71,27 @@ describe("whatsapp helper", () => { it("finds the compiled helper under the repo root when available", () => { const repoRoot = createRepoRoot(); - const candidates = getWhatsAppHelperBinaryCandidates(repoRoot); mkdirSync(join(repoRoot, "native", "helpers", "whatsapp-go", ".build"), { recursive: true, }); - writeFileSync(candidates[0], "#!/bin/sh\nexit 0\n"); - chmodSync(candidates[0], 0o755); + const helperPath = join( + repoRoot, + "native", + "helpers", + "whatsapp-go", + ".build", + "cued-whatsapp-helper", + ); + writeFileSync(helperPath, "#!/bin/sh\nexit 0\n"); + chmodSync(helperPath, 0o755); - expect(resolveWhatsAppHelperBinary(undefined, repoRoot)).toBe(candidates[0]); + expect(resolveWhatsAppHelperBinary(undefined, repoRoot)).toBe(helperPath); }); it("parses extended helper history status fields", async () => { const repoRoot = createRepoRoot(); const helperPath = join(repoRoot, "cued-whatsapp-helper"); - process.env.CUED_WHATSAPP_HELPER_BINARY = helperPath; + WHATSAPP_HELPER_CONFIG.helperBinary = helperPath; writeFileSync( helperPath, `#!/bin/sh diff --git a/src/platforms/whatsapp/helper/pair.ts b/src/platforms/whatsapp/helper/pair.ts index eb34e930..b8e6f3bf 100644 --- a/src/platforms/whatsapp/helper/pair.ts +++ b/src/platforms/whatsapp/helper/pair.ts @@ -10,10 +10,21 @@ import type { WhatsAppHelperEventEnvelope } from "../types.js"; const execFileAsync = promisify(execFile); +export const WHATSAPP_HELPER_CONFIG = { + helperBinary: null as string | null, +}; + function resolveRepoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); } +function runtimeBundledWhatsAppHelperBinary(): string { + return resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../../helpers/cued-whatsapp-helper", + ); +} + export function getWhatsAppStoreDir(accountKey: string): string { ensureCuedDirs(); const dir = join(getWhatsAppStoreRoot(), validateIntegrationAccountKey(accountKey)); @@ -24,6 +35,7 @@ export function getWhatsAppStoreDir(accountKey: string): string { export function getWhatsAppHelperBinaryCandidates(repoRoot = resolveRepoRoot()): string[] { const helperRoot = join(repoRoot, "native", "helpers", "whatsapp-go"); return [ + runtimeBundledWhatsAppHelperBinary(), join(helperRoot, ".build", "cued-whatsapp-helper"), join(helperRoot, ".build", "CuedWhatsAppHelper"), join(helperRoot, "cued-whatsapp-helper"), @@ -31,11 +43,11 @@ export function getWhatsAppHelperBinaryCandidates(repoRoot = resolveRepoRoot()): } export function resolveWhatsAppHelperBinary( - envVarValue = process.env.CUED_WHATSAPP_HELPER_BINARY, + explicitPath = WHATSAPP_HELPER_CONFIG.helperBinary, repoRoot = resolveRepoRoot(), ): string | null { - if (envVarValue) { - return envVarValue; + if (explicitPath) { + return explicitPath; } return ( diff --git a/src/platforms/whatsapp/sync/worker.ts b/src/platforms/whatsapp/sync/worker.ts index 536b09ba..f5ac4a1a 100644 --- a/src/platforms/whatsapp/sync/worker.ts +++ b/src/platforms/whatsapp/sync/worker.ts @@ -1,17 +1,14 @@ -import { readAdapterInvocationEnv } from "../../core/invocation.js"; +import { readAdapterInvocation } from "../../core/invocation.js"; import type { AdapterWorkerOutput } from "../../core/sync.js"; import { buildWhatsAppDesktopSyncBundle } from "../desktop.js"; async function main(): Promise { try { - const invocation = readAdapterInvocationEnv("whatsapp"); - if ( - process.env.CUED_WHATSAPP_SYNC_SOURCE === "desktop_db" || - process.env.CUED_WHATSAPP_DESKTOP_SOURCE_PATH - ) { + const invocation = await readAdapterInvocation(); + if (invocation.whatsappSource === "desktop_db" || invocation.whatsappDesktopSourcePath) { const bundle = buildWhatsAppDesktopSyncBundle({ - sourcePath: process.env.CUED_WHATSAPP_DESKTOP_SOURCE_PATH || undefined, - accountKey: process.env.CUED_ACCOUNT_KEY || "default", + sourcePath: invocation.whatsappDesktopSourcePath, + accountKey: invocation.accountKey || "default", }); if (typeof invocation.sourceCursor === "object" && invocation.sourceCursor) { bundle.sourceCursor = { diff --git a/src/runtime/attachments.ts b/src/runtime/attachments.ts index f053892a..415174e6 100644 --- a/src/runtime/attachments.ts +++ b/src/runtime/attachments.ts @@ -214,19 +214,8 @@ function materializeObjectPath(sha256: string, extension: string): string { return join(CUED_ATTACHMENTS_OBJECTS_DIR, `${sha256}${normalizedExtension}`); } -function parsePositiveInteger(value: string | undefined): number | null { - if (!value) { - return null; - } - const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -} - function attachmentDiskReserveBytes(): number { - return ( - parsePositiveInteger(process.env.CUED_ATTACHMENT_DISK_RESERVE_BYTES) ?? - DEFAULT_ATTACHMENT_DISK_RESERVE_BYTES - ); + return DEFAULT_ATTACHMENT_DISK_RESERVE_BYTES; } function availableBytesForPath(path: string): number | null { diff --git a/src/runtime/daemon/server.test.ts b/src/runtime/daemon/server.test.ts index 192a2a98..ee0d165b 100644 --- a/src/runtime/daemon/server.test.ts +++ b/src/runtime/daemon/server.test.ts @@ -320,28 +320,18 @@ describe("interactive auth sessions", () => { }); describe("sync resume targets", () => { - it("allows autosync to be explicitly disabled", () => { - const previous = process.env.CUED_AUTOSYNC_PLATFORMS; - process.env.CUED_AUTOSYNC_PLATFORMS = "none"; - try { - expect( - getAutoSyncTargets({ - listEnabledSyncTargets: () => [ - { - platform: "imessage", - account_key: "local", - }, - ], - listIntegrationStates: () => [], - }), - ).toEqual([]); - } finally { - if (previous == null) { - delete process.env.CUED_AUTOSYNC_PLATFORMS; - } else { - process.env.CUED_AUTOSYNC_PLATFORMS = previous; - } - } + it("uses enabled sync targets before falling back to default autosync platforms", () => { + expect( + getAutoSyncTargets({ + listEnabledSyncTargets: () => [ + { + platform: "imessage", + account_key: "local", + }, + ], + listIntegrationStates: () => [], + }), + ).toEqual([{ platform: "imessage", accountKey: "local" }]); }); it("preserves account keys that contain colons", () => { diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index 28286d63..f9c5ecbe 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -4,7 +4,13 @@ import { createConnection, createServer, type Socket } from "node:net"; import { basename, dirname } from "node:path"; import process from "node:process"; import { getCurrentAppVersion, getCurrentReleaseChannel } from "../../core/app-metadata.js"; -import { CUED_DAEMON_LOCK_PATH, CUED_SOCKET_PATH } from "../../core/config.js"; +import { + CUED_DAEMON_LOCK_PATH, + CUED_SOCKET_PATH, + DAEMON_CONFIG, + getRuntimeConfigArgs, + IMESSAGE_CONFIG, +} from "../../core/config.js"; import { createLogger } from "../../core/logging.js"; import { acquireSingletonLock, @@ -24,8 +30,9 @@ import { } from "../../core/types/provider.js"; import { safeParseJsonRecord, safeParseJsonStringArray } from "../../db/codecs.js"; import { type CuedDatabase, type OutboundMessageRow, openCuedDatabase } from "../../db/database.js"; +import { getCurrentAppPath } from "../../macos/install.js"; import { - buildAdapterInvocationEnv, + buildAdapterInvocation, selectAdapterInvocationProofs, } from "../../platforms/core/invocation.js"; import { isAdapterPlatform, listAutoSyncPlatforms } from "../../platforms/core/registry.js"; @@ -51,7 +58,6 @@ import { } from "../../platforms/discord/sync/events.js"; import { isDiscordDmChannel } from "../../platforms/discord/types.js"; import { GmailClient } from "../../platforms/gmail/api/client.js"; -import { DEFAULT_CALL_HISTORY_DB_PATH } from "../../platforms/imessage/call-history.js"; import { DEFAULT_CHAT_DB_PATH } from "../../platforms/imessage/reader.js"; import { loadLinkedInSessionSecret } from "../../platforms/linkedin/auth/session-store.js"; import { buildLinkedInRawEventsFromRealtimeEnvelope } from "../../platforms/linkedin/realtime/events.js"; @@ -152,42 +158,12 @@ import { } from "./local-watchers.js"; const DAEMON_VERSION = getCurrentAppVersion(); -const DEFAULT_AUTOSYNC_INTERVAL_MS = 60_000; -const DEFAULT_DISCORD_AUTOSYNC_INTERVAL_MS = 10 * 60_000; -const DEFAULT_SIGNAL_CATCHUP_INTERVAL_MS = 300_000; -const DEFAULT_WHATSAPP_CATCHUP_INTERVAL_MS = 300_000; -const DEFAULT_DISCORD_REALTIME_ENABLED = true; -const DEFAULT_DISCORD_DM_POLL_MS = 45_000; -const DEFAULT_SLACK_REALTIME_ENABLED = false; -const DEFAULT_INGEST_CONCURRENCY = 4; -const DEFAULT_PROJECTION_BATCH_SIZE = 100; -const DEFAULT_MESSAGE_FTS_INDEX_BATCH_SIZE = 250; -const DEFAULT_REALTIME_PROJECTION_ENABLED = true; -const DEFAULT_INLINE_PROJECTION_MAX_RAW_EVENTS = 250; -const DEFAULT_DEFERRED_PROJECTION_COALESCE_MS = 250; -const DEFAULT_AUTOSYNC_PROJECTION_BACKLOG_PAUSE_EVENTS = 5_000; -const DEFAULT_PROJECTION_CONTINUE_DELAY_MS = 5_000; -const PROJECTION_BACKLOG_TIERS = [ - { minEvents: 100_000, batchSize: 2_000, continueDelayMs: 0 }, - { minEvents: 25_000, batchSize: 1_500, continueDelayMs: 0 }, - { minEvents: 5_000, batchSize: 1_000, continueDelayMs: 0 }, - { minEvents: 1_000, batchSize: 500, continueDelayMs: null }, -] as const; const FTS_PROJECTION_THROTTLE_BACKLOG_EVENTS = 0; const FTS_PROJECTION_THROTTLE_DELAY_MS = 5_000; const FTS_INDEXING_STALE_AFTER_MS = 5 * 60 * 1000; const PROJECTION_AUTH_RETRY_DELAY_MS = 250; const PROJECTION_AUTH_GRACE_MS = 2_000; -const DEFAULT_INTERACTIVE_AUTH_TTL_MS = 45_000; -const DEFAULT_INTERACTIVE_AUTH_PROJECTION_BATCH_SIZE = 25; -const DEFAULT_INTERACTIVE_AUTH_PROJECTION_CONTINUE_DELAY_MS = 5_000; -const DEFAULT_INTERACTIVE_AUTH_SYNC_CONTINUE_DELAY_MS = 30_000; -const DEFAULT_CONTINUATION_PROJECTION_INTERVAL_MS = 2_000; -const DEFAULT_CONTINUATION_PROJECTION_BACKLOG_EVENTS = 500; const NATIVE_WATCH_DEBOUNCE_MS = 1_500; -const DEFAULT_AUTOSYNC_SCHEDULER_TICK_MS = 15_000; -const DEFAULT_SYNC_CONTINUE_DELAY_MS = 15_000; -const DEFAULT_SIGNAL_RECONNECT_SYNC_COOLDOWN_MS = 5 * 60_000; const DAEMON_STATUS_BUSY_TIMEOUT_MS = 100; const MENU_BAR_STATUS_WRITE_INTERVAL_MS = 15_000; const MENU_BAR_STATUS_EVENT_DEBOUNCE_MS = 500; @@ -283,10 +259,9 @@ function getDaemonIdentity(): { executablePath: string; appPath?: string; } { - const appPath = process.env.CUED_APP_PATH?.trim() || undefined; return { executablePath: process.execPath, - appPath, + appPath: getCurrentAppPath() ?? undefined, }; } @@ -361,34 +336,13 @@ type PendingSignalEcho = { }; function getConfiguredAutoSyncPlatforms(): AdapterPlatform[] | null { - const raw = process.env.CUED_AUTOSYNC_PLATFORMS?.trim(); - if (raw == null) { - return null; - } - if (raw === "" || ["0", "false", "none", "off"].includes(raw.toLowerCase())) { - return []; - } - const configured = raw - .split(",") - .map((value) => value.trim()) - .filter(isAdapterPlatform); - return [...new Set(configured)]; + return DAEMON_CONFIG.autoSyncPlatforms == null + ? null + : [...new Set(DAEMON_CONFIG.autoSyncPlatforms)]; } function getConfiguredRealtimePlatforms(): Set | null { - const raw = process.env.CUED_REALTIME_PLATFORMS?.trim(); - if (!raw) { - return null; - } - if (["0", "false", "none", "off"].includes(raw.toLowerCase())) { - return new Set(); - } - return new Set( - raw - .split(",") - .map((value) => value.trim()) - .filter(isAdapterPlatform), - ); + return DAEMON_CONFIG.realtimePlatforms == null ? null : new Set(DAEMON_CONFIG.realtimePlatforms); } export function getAutoSyncTargets( @@ -486,32 +440,13 @@ export async function requestWhatsAppHistoryBackfillOnce(input: { function getAutoSyncIntervalMs(platform?: AdapterPlatform): number { if (platform) { - const platformEnvName = `CUED_AUTOSYNC_INTERVAL_${platform.toUpperCase()}_MS`; - const platformConfigured = Number(process.env[platformEnvName]); - if (Number.isFinite(platformConfigured) && platformConfigured > 0) { + const platformConfigured = DAEMON_CONFIG.platformAutoSyncIntervalMs[platform]; + if (platformConfigured != null) { return platformConfigured; } } - const globalConfiguredRaw = process.env.CUED_AUTOSYNC_INTERVAL_MS; - const globalConfigured = Number(globalConfiguredRaw); - if (globalConfiguredRaw != null && Number.isFinite(globalConfigured) && globalConfigured > 0) { - return globalConfigured; - } - - if (platform === "signal") { - return DEFAULT_SIGNAL_CATCHUP_INTERVAL_MS; - } - - if (platform === "whatsapp") { - return DEFAULT_WHATSAPP_CATCHUP_INTERVAL_MS; - } - - if (platform === "discord") { - return DEFAULT_DISCORD_AUTOSYNC_INTERVAL_MS; - } - - return DEFAULT_AUTOSYNC_INTERVAL_MS; + return DAEMON_CONFIG.autoSyncIntervalMs; } export function shouldSkipConnectedDiscordSchedulerSync( @@ -545,15 +480,12 @@ export function shouldProjectIngestRunInline(input: { input.firstInsertedRowId != null && input.lastInsertedRowId != null && input.insertedRawEvents > 0 && - input.insertedRawEvents <= DEFAULT_INLINE_PROJECTION_MAX_RAW_EVENTS + input.insertedRawEvents <= DAEMON_CONFIG.inlineProjectionMaxRawEvents ); } function getAutoSyncSchedulerTickMs(): number { - const configured = Number(process.env.CUED_AUTOSYNC_SCHEDULER_TICK_MS); - return Number.isFinite(configured) && configured > 0 - ? Math.trunc(configured) - : DEFAULT_AUTOSYNC_SCHEDULER_TICK_MS; + return DAEMON_CONFIG.autoSyncSchedulerTickMs; } function isSqliteBusyError(error: unknown): boolean { @@ -562,43 +494,27 @@ function isSqliteBusyError(error: unknown): boolean { } function getIngestConcurrency(): number { - const configured = Number(process.env.CUED_INGEST_CONCURRENCY ?? DEFAULT_INGEST_CONCURRENCY); - return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_INGEST_CONCURRENCY; + return DAEMON_CONFIG.ingestConcurrency; } function getInteractiveAuthTtlMs(): number { - const configured = Number(process.env.CUED_INTERACTIVE_AUTH_TTL_MS); - return Number.isFinite(configured) && configured > 0 - ? Math.trunc(configured) - : DEFAULT_INTERACTIVE_AUTH_TTL_MS; + return DAEMON_CONFIG.interactiveAuth.ttlMs; } function getInteractiveAuthProjectionBatchSize(): number { - const configured = Number(process.env.CUED_INTERACTIVE_AUTH_PROJECTION_BATCH_SIZE); - return Number.isFinite(configured) && configured > 0 - ? Math.trunc(configured) - : DEFAULT_INTERACTIVE_AUTH_PROJECTION_BATCH_SIZE; + return DAEMON_CONFIG.interactiveAuth.projectionBatchSize; } function getInteractiveAuthSyncContinueDelayMs(): number { - const configured = Number(process.env.CUED_INTERACTIVE_AUTH_SYNC_CONTINUE_DELAY_MS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_INTERACTIVE_AUTH_SYNC_CONTINUE_DELAY_MS; + return DAEMON_CONFIG.interactiveAuth.syncContinueDelayMs; } function getInteractiveAuthProjectionContinueDelayMs(): number { - const configured = Number(process.env.CUED_INTERACTIVE_AUTH_PROJECTION_CONTINUE_DELAY_MS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_INTERACTIVE_AUTH_PROJECTION_CONTINUE_DELAY_MS; + return DAEMON_CONFIG.interactiveAuth.projectionContinueDelayMs; } function getProjectionBatchSize(): number { - const configured = Number( - process.env.CUED_PROJECTION_BATCH_SIZE ?? DEFAULT_PROJECTION_BATCH_SIZE, - ); - return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_PROJECTION_BATCH_SIZE; + return DAEMON_CONFIG.projectionBatchSize; } export function getAdaptiveProjectionBatchSize( @@ -606,7 +522,7 @@ export function getAdaptiveProjectionBatchSize( options?: { maxBatchSize?: number }, ): number { const baseBatchSize = getProjectionBatchSize(); - const tier = PROJECTION_BACKLOG_TIERS.find( + const tier = DAEMON_CONFIG.projectionBacklogTiers.find( (candidate) => pendingRawEvents >= candidate.minEvents, ); const adaptiveBatchSize = Math.max(baseBatchSize, tier?.batchSize ?? baseBatchSize); @@ -663,53 +579,27 @@ export class InteractiveAuthSessions { } function getMessageFtsIndexBatchSize(): number { - const configured = Number( - process.env.CUED_MESSAGE_FTS_INDEX_BATCH_SIZE ?? DEFAULT_MESSAGE_FTS_INDEX_BATCH_SIZE, - ); - return Number.isFinite(configured) && configured > 0 - ? configured - : DEFAULT_MESSAGE_FTS_INDEX_BATCH_SIZE; + return DAEMON_CONFIG.messageFtsIndexBatchSize; } function getRealtimeProjectionEnabled(): boolean { - const configured = process.env.CUED_REALTIME_PROJECTION_ENABLED; - if (configured == null) { - return DEFAULT_REALTIME_PROJECTION_ENABLED; - } - - return !["0", "false", "off", "no"].includes(configured.trim().toLowerCase()); + return DAEMON_CONFIG.realtimeProjectionEnabled; } function getSlackRealtimeEnabled(): boolean { - const configured = process.env.CUED_SLACK_REALTIME_ENABLED; - if (configured == null) { - return DEFAULT_SLACK_REALTIME_ENABLED; - } - - return !["0", "false", "off", "no"].includes(configured.trim().toLowerCase()); + return DAEMON_CONFIG.slackRealtime.enabled; } function getDiscordRealtimeEnabled(): boolean { - const configured = process.env.CUED_DISCORD_REALTIME_ENABLED; - if (configured == null) { - return DEFAULT_DISCORD_REALTIME_ENABLED; - } - - return !["0", "false", "off", "no"].includes(configured.trim().toLowerCase()); + return DAEMON_CONFIG.discordRealtime.enabled; } function getRealtimeProjectionBatchSize(): number { - const configured = Number( - process.env.CUED_REALTIME_PROJECTION_BATCH_SIZE ?? getProjectionBatchSize(), - ); - return Number.isFinite(configured) && configured > 0 ? configured : getProjectionBatchSize(); + return DAEMON_CONFIG.realtimeProjectionBatchSize ?? getProjectionBatchSize(); } function getSyncContinueDelayMs(): number { - const configured = Number(process.env.CUED_SYNC_CONTINUE_DELAY_MS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_SYNC_CONTINUE_DELAY_MS; + return DAEMON_CONFIG.syncContinueDelayMs; } function getSyncContinueDelayMsForPlatform(platform: Platform): number { @@ -720,58 +610,37 @@ function getSyncContinueDelayMsForPlatform(platform: Platform): number { } function getSignalReconnectSyncCooldownMs(): number { - const configured = Number(process.env.CUED_SIGNAL_RECONNECT_SYNC_COOLDOWN_MS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_SIGNAL_RECONNECT_SYNC_COOLDOWN_MS; + return DAEMON_CONFIG.signalReconnectSyncCooldownMs; } function getDeferredProjectionCoalesceMs(): number { - const configured = Number( - process.env.CUED_DEFERRED_PROJECTION_COALESCE_MS ?? DEFAULT_DEFERRED_PROJECTION_COALESCE_MS, - ); - return Number.isFinite(configured) && configured >= 0 - ? configured - : DEFAULT_DEFERRED_PROJECTION_COALESCE_MS; + return DAEMON_CONFIG.deferredProjectionCoalesceMs; } function getAutoSyncProjectionBacklogPauseEvents(): number { - const configured = Number(process.env.CUED_AUTOSYNC_PROJECTION_BACKLOG_PAUSE_EVENTS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_AUTOSYNC_PROJECTION_BACKLOG_PAUSE_EVENTS; + return DAEMON_CONFIG.autoSyncProjectionBacklogPauseEvents; } function getProjectionContinueDelayMs(): number { - const configured = Number(process.env.CUED_PROJECTION_CONTINUE_DELAY_MS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_PROJECTION_CONTINUE_DELAY_MS; + return DAEMON_CONFIG.projectionContinueDelayMsOverride ?? DAEMON_CONFIG.projectionContinueDelayMs; } export function getAdaptiveProjectionContinueDelayMs(pendingRawEvents: number): number { - const configured = process.env.CUED_PROJECTION_CONTINUE_DELAY_MS; - if (configured != null) { + if (DAEMON_CONFIG.projectionContinueDelayMsOverride != null) { return getProjectionContinueDelayMs(); } - const tier = PROJECTION_BACKLOG_TIERS.find( + const tier = DAEMON_CONFIG.projectionBacklogTiers.find( (candidate) => pendingRawEvents >= candidate.minEvents && candidate.continueDelayMs != null, ); return tier?.continueDelayMs ?? getProjectionContinueDelayMs(); } function getContinuationProjectionIntervalMs(): number { - const configured = Number(process.env.CUED_CONTINUATION_PROJECTION_INTERVAL_MS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_CONTINUATION_PROJECTION_INTERVAL_MS; + return DAEMON_CONFIG.continuationProjectionIntervalMs; } function getContinuationProjectionBacklogEvents(): number { - const configured = Number(process.env.CUED_CONTINUATION_PROJECTION_BACKLOG_EVENTS); - return Number.isFinite(configured) && configured >= 0 - ? Math.trunc(configured) - : DEFAULT_CONTINUATION_PROJECTION_BACKLOG_EVENTS; + return DAEMON_CONFIG.continuationProjectionBacklogEvents; } export function shouldDeferContinuationProjection(input: { @@ -824,15 +693,16 @@ function runProjectionWorkerProcess( projectionBatchSize: number, ): Promise { return new Promise((resolve, reject) => { - const child = spawn(process.execPath, [resolveCliEntrypoint(), "__projection-worker"], { - stdio: ["ignore", "pipe", "pipe"], - env: { - ...process.env, - CUED_PROJECTION_WORKER_RUN: JSON.stringify(run), - CUED_PROJECTION_BATCH_SIZE: String(projectionBatchSize), + const child = spawn( + process.execPath, + [resolveCliEntrypoint(), "__projection-worker", ...getRuntimeConfigArgs()], + { + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + detached: false, }, - detached: false, - }); + ); + child.stdin?.end(JSON.stringify({ run, projectionBatchSize })); let stdout = ""; let stderr = ""; @@ -992,8 +862,7 @@ function summarizeRawEventsBySchema( } function getWhatsAppResyncPageBudget(): number { - const configured = Number(process.env.CUED_WHATSAPP_RESYNC_PAGE_BUDGET ?? 10); - return Number.isFinite(configured) && configured > 0 ? Math.trunc(configured) : 10; + return DAEMON_CONFIG.whatsappResyncPageBudget; } async function safeEmitHookEvent( @@ -1116,7 +985,7 @@ function startIMessageWatcher( _db: ReturnType, queueSync: (platform: AdapterPlatform, accountKey: string, trigger: string) => void, ): FSWatcher | ChildProcess | null { - const nativeBinary = resolveMacOSNativeBinary(process.env.CUED_IMESSAGE_NATIVE_BINARY); + const nativeBinary = resolveMacOSNativeBinary(); if (nativeBinary) { const child = spawn(nativeBinary, ["imessage", "watch", "--db-path", DEFAULT_CHAT_DB_PATH], { stdio: ["ignore", "pipe", "pipe"], @@ -1180,8 +1049,8 @@ function startCallHistoryWatcher( _db: ReturnType, queueSync: (platform: AdapterPlatform, accountKey: string, trigger: string) => void, ): FSWatcher | ChildProcess | null { - const nativeBinary = resolveMacOSNativeBinary(process.env.CUED_IMESSAGE_NATIVE_BINARY); - const dbPath = process.env.CUED_CALL_HISTORY_DB_PATH || DEFAULT_CALL_HISTORY_DB_PATH; + const nativeBinary = resolveMacOSNativeBinary(); + const dbPath = IMESSAGE_CONFIG.callHistoryDbPath; if (nativeBinary) { const child = spawn(nativeBinary, ["callhistory", "watch", "--db-path", dbPath], { stdio: ["ignore", "pipe", "pipe"], @@ -1245,7 +1114,7 @@ function startContactsWatcher( _db: ReturnType, queueSync: (platform: AdapterPlatform, accountKey: string, trigger: string) => void, ): ChildProcess | null { - const nativeBinary = resolveMacOSNativeBinary(process.env.CUED_CONTACTS_NATIVE_BINARY); + const nativeBinary = resolveMacOSNativeBinary(); if (!nativeBinary) { return null; } @@ -1502,9 +1371,7 @@ async function collectDesiredDiscordSessions(db: ReturnType { checkpoint?.source_cursor_json ?? null, "sync_checkpoints.source_cursor_json", ); - const envOverrides = { - ...buildAdapterInvocationEnv({ + const adapterInvocation = { + ...buildAdapterInvocation({ platform, + accountKey, checkpointSourceCursorJson: checkpoint?.source_cursor_json ?? null, proofs: selectAdapterInvocationProofs({ platform, @@ -3991,7 +3849,7 @@ export async function runDaemon(): Promise { }), }), ...(platform === "whatsapp" && runDetails?.source === "whatsapp_desktop" - ? { CUED_WHATSAPP_SYNC_SOURCE: "desktop_db" } + ? ({ whatsappSource: "desktop_db" } as const) : {}), }; @@ -4202,9 +4060,9 @@ export async function runDaemon(): Promise { platform === "signal" ? await runSignalSyncExclusively( accountKey, - async () => await runAdapter(platform, accountKey, envOverrides), + async () => await runAdapter(platform, adapterInvocation), ) - : await runAdapter(platform, accountKey, envOverrides); + : await runAdapter(platform, adapterInvocation); adapterFetchMs = now() - adapterStartedAt; db.updateRunProgress(currentRun.id, { leaseMs: INGEST_RUN_LEASE_MS, claim: runClaim }); sourceAccounts = bundle.sourceAccounts as typeof sourceAccounts; diff --git a/src/runtime/doctor-status.test.ts b/src/runtime/doctor-status.test.ts index 71bbcc4b..62ee6b9e 100644 --- a/src/runtime/doctor-status.test.ts +++ b/src/runtime/doctor-status.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -15,6 +15,7 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +import { NATIVE_RUNTIME_CONFIG } from "../core/config.js"; import { CuedDatabase } from "../db/database.js"; import { buildPermissionStatus } from "./doctor.js"; @@ -22,10 +23,10 @@ const itDarwin = process.platform === "darwin" ? it : it.skip; describe("permission status modes", () => { const tempDirs: string[] = []; + const originalNativeRepoRoot = NATIVE_RUNTIME_CONFIG.repoRoot; afterEach(() => { - delete process.env.CUED_CONTACTS_NATIVE_BINARY; - delete process.env.CUED_IMESSAGE_NATIVE_BINARY; + NATIVE_RUNTIME_CONFIG.repoRoot = originalNativeRepoRoot; vi.clearAllMocks(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -43,16 +44,34 @@ describe("permission status modes", () => { return db; } + function installNativeBinary(): string { + const repoRoot = mkdtempSync(join(tmpdir(), "cued-doctor-native-repo-")); + tempDirs.push(repoRoot); + NATIVE_RUNTIME_CONFIG.repoRoot = repoRoot; + const binaryPath = join( + repoRoot, + "native", + "macos", + "CuedNative", + ".build", + "release", + "CuedNative", + ); + mkdirSync(join(binaryPath, ".."), { recursive: true }); + writeFileSync(binaryPath, "#!/bin/sh\nexit 0\n"); + chmodSync(binaryPath, 0o755); + return binaryPath; + } + itDarwin("omits removed permission checks in passive mode", async () => { const db = createDb(); - process.env.CUED_CONTACTS_NATIVE_BINARY = "/tmp/cued-native-helper"; - process.env.CUED_IMESSAGE_NATIVE_BINARY = "/tmp/cued-native-helper"; + const nativeBinary = installNativeBinary(); execFileSyncMock.mockImplementation((command: string, args?: string[]) => { - if (command === "/tmp/cued-native-helper" && args?.[0] === "contacts") { + if (command === nativeBinary && args?.[0] === "contacts") { return '{"status":"authorized"}'; } - if (command === "/tmp/cued-native-helper" && args?.[0] === "imessage") { + if (command === nativeBinary && args?.[0] === "imessage") { return ""; } throw new Error(`unexpected command: ${command}`); diff --git a/src/runtime/doctor.ts b/src/runtime/doctor.ts index a109c8db..2343c091 100644 --- a/src/runtime/doctor.ts +++ b/src/runtime/doctor.ts @@ -200,7 +200,7 @@ function tryReadMessagesDatabase(): DoctorCheck { } function getMessagesNativeHelperCheck(): DoctorCheck { - const nativeBinary = resolveMacOSNativeBinary(process.env.CUED_IMESSAGE_NATIVE_BINARY); + const nativeBinary = resolveMacOSNativeBinary(); if (!nativeBinary) { return { name: "messages_native_helper", @@ -244,7 +244,7 @@ function getMessagesNativeHelperCheck(): DoctorCheck { } function getContactsPermissionCheck(): DoctorCheck { - const nativeBinary = resolveMacOSNativeBinary(process.env.CUED_CONTACTS_NATIVE_BINARY); + const nativeBinary = resolveMacOSNativeBinary(); if (!nativeBinary) { return { name: "contacts_permission", diff --git a/src/runtime/native-binary.test.ts b/src/runtime/native-binary.test.ts index ae0db2dc..e8710793 100644 --- a/src/runtime/native-binary.test.ts +++ b/src/runtime/native-binary.test.ts @@ -2,7 +2,12 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { getMacOSNativeBinaryCandidates, resolveMacOSNativeBinary } from "./native-binary.js"; +import { + getMacOSAppExecutableCandidates, + getMacOSNativeBinaryCandidates, + resolveMacOSAppExecutable, + resolveMacOSNativeBinary, +} from "./native-binary.js"; describe("macOS native binary resolution", () => { const tempDirs: string[] = []; @@ -14,7 +19,6 @@ describe("macOS native binary resolution", () => { rmSync(dir, { recursive: true, force: true }); } } - delete process.env.CUED_APP_PATH; }); function createRepoRoot(prefix = "cued-native-binary-"): string { @@ -23,26 +27,6 @@ describe("macOS native binary resolution", () => { return dir; } - it("returns explicit env override first", () => { - const repoRoot = createRepoRoot(); - expect(resolveMacOSNativeBinary("/tmp/cued-native", repoRoot)).toBe("/tmp/cued-native"); - }); - - it("prefers the helper bundled in CUED_APP_PATH", () => { - const dir = createRepoRoot("cued-native-helper-"); - const appPath = join(dir, "Cued.app"); - const helperPath = join(appPath, "Contents", "Resources", "helpers", "cued-native-helper"); - mkdirSync(join(appPath, "Contents", "Resources", "helpers"), { recursive: true }); - writeFileSync(helperPath, ""); - - const env = { CUED_APP_PATH: appPath } as NodeJS.ProcessEnv; - - expect(getMacOSNativeBinaryCandidates(dir, env)[0]).toBe(helperPath); - expect(resolveMacOSNativeBinary(undefined, dir)).toBe(null); - process.env.CUED_APP_PATH = appPath; - expect(resolveMacOSNativeBinary(undefined, dir)).toBe(helperPath); - }); - it("finds the compiled binary in the default release location", () => { const repoRoot = createRepoRoot(); const releasePath = join( @@ -60,12 +44,12 @@ describe("macOS native binary resolution", () => { writeFileSync(releasePath, "#!/bin/sh\nexit 0\n"); chmodSync(releasePath, 0o755); - expect(resolveMacOSNativeBinary(undefined, repoRoot)).toBe(releasePath); + expect(resolveMacOSNativeBinary(repoRoot)).toBe(releasePath); }); it("returns null when nothing is compiled", () => { const repoRoot = createRepoRoot(); - expect(resolveMacOSNativeBinary(undefined, repoRoot)).toBeNull(); + expect(resolveMacOSNativeBinary(repoRoot)).toBeNull(); }); it("uses packaged and development candidates for implicit resolution", () => { @@ -75,4 +59,32 @@ describe("macOS native binary resolution", () => { join(process.cwd(), "native", "macos", "CuedNative", ".build", "release", "CuedNative"), ); }); + + it("resolves the packaged app executable before development candidates for native UI commands", () => { + const candidates = getMacOSAppExecutableCandidates(); + expect(candidates[0]).toContain(join("MacOS", "CuedDaemon")); + expect(candidates).toContain( + join(process.cwd(), "native", "macos", "CuedNative", ".build", "release", "CuedNative"), + ); + }); + + it("finds the development app executable fallback when present", () => { + const repoRoot = createRepoRoot(); + const releasePath = join( + repoRoot, + "native", + "macos", + "CuedNative", + ".build", + "release", + "CuedNative", + ); + mkdirSync(join(repoRoot, "native", "macos", "CuedNative", ".build", "release"), { + recursive: true, + }); + writeFileSync(releasePath, "#!/bin/sh\nexit 0\n"); + chmodSync(releasePath, 0o755); + + expect(resolveMacOSAppExecutable(repoRoot)).toBe(releasePath); + }); }); diff --git a/src/runtime/native-binary.ts b/src/runtime/native-binary.ts index ae374f44..1444e070 100644 --- a/src/runtime/native-binary.ts +++ b/src/runtime/native-binary.ts @@ -1,12 +1,11 @@ import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { NATIVE_RUNTIME_CONFIG } from "../core/config.js"; const NATIVE_HELPER_BINARY_NAME = "cued-native-helper"; - -function resolveRepoRoot(): string { - return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -} +const NATIVE_APP_EXECUTABLE_NAME = "CuedDaemon"; +const DEVELOPMENT_NATIVE_BINARY_NAME = "CuedNative"; function runtimeBundledNativeHelperBinary(): string { return resolve( @@ -16,40 +15,48 @@ function runtimeBundledNativeHelperBinary(): string { ); } -export function getMacOSNativeBinaryCandidates( - repoRoot = resolveRepoRoot(), - env: NodeJS.ProcessEnv = process.env, -): string[] { +function runtimeBundledAppExecutable(): string { + return resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../MacOS", + NATIVE_APP_EXECUTABLE_NAME, + ); +} + +function getDevelopmentNativeBinaryCandidates(repoRoot = NATIVE_RUNTIME_CONFIG.repoRoot): string[] { const packageRoot = join(repoRoot, "native", "macos", "CuedNative"); return [ - env.CUED_APP_PATH?.trim() - ? join( - env.CUED_APP_PATH.trim(), - "Contents", - "Resources", - "helpers", - NATIVE_HELPER_BINARY_NAME, - ) - : null, - runtimeBundledNativeHelperBinary(), - join(packageRoot, ".build", "release", "CuedNative"), - join(packageRoot, ".build", "debug", "CuedNative"), - join(packageRoot, ".build", "arm64-apple-macosx", "release", "CuedNative"), - join(packageRoot, ".build", "arm64-apple-macosx", "debug", "CuedNative"), - join(packageRoot, ".build", "x86_64-apple-macosx", "release", "CuedNative"), - join(packageRoot, ".build", "x86_64-apple-macosx", "debug", "CuedNative"), - ].filter((value): value is string => Boolean(value)); + join(packageRoot, ".build", "release", DEVELOPMENT_NATIVE_BINARY_NAME), + join(packageRoot, ".build", "debug", DEVELOPMENT_NATIVE_BINARY_NAME), + join(packageRoot, ".build", "arm64-apple-macosx", "release", DEVELOPMENT_NATIVE_BINARY_NAME), + join(packageRoot, ".build", "arm64-apple-macosx", "debug", DEVELOPMENT_NATIVE_BINARY_NAME), + join(packageRoot, ".build", "x86_64-apple-macosx", "release", DEVELOPMENT_NATIVE_BINARY_NAME), + join(packageRoot, ".build", "x86_64-apple-macosx", "debug", DEVELOPMENT_NATIVE_BINARY_NAME), + ]; } -export function resolveMacOSNativeBinary( - envVarValue: string | undefined, - repoRoot = resolveRepoRoot(), -): string | null { - if (envVarValue) { - return envVarValue; - } +export function getMacOSNativeBinaryCandidates( + repoRoot = NATIVE_RUNTIME_CONFIG.repoRoot, +): string[] { + return [runtimeBundledNativeHelperBinary(), ...getDevelopmentNativeBinaryCandidates(repoRoot)]; +} + +export function getMacOSAppExecutableCandidates( + repoRoot = NATIVE_RUNTIME_CONFIG.repoRoot, +): string[] { + return [runtimeBundledAppExecutable(), ...getDevelopmentNativeBinaryCandidates(repoRoot)]; +} +export function resolveMacOSNativeBinary(repoRoot = NATIVE_RUNTIME_CONFIG.repoRoot): string | null { return ( getMacOSNativeBinaryCandidates(repoRoot).find((candidate) => existsSync(candidate)) ?? null ); } + +export function resolveMacOSAppExecutable( + repoRoot = NATIVE_RUNTIME_CONFIG.repoRoot, +): string | null { + return ( + getMacOSAppExecutableCandidates(repoRoot).find((candidate) => existsSync(candidate)) ?? null + ); +} diff --git a/src/runtime/onboarding.test.ts b/src/runtime/onboarding.test.ts index 1fa6faec..c1815840 100644 --- a/src/runtime/onboarding.test.ts +++ b/src/runtime/onboarding.test.ts @@ -2,18 +2,30 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { + IMESSAGE_CONFIG, + NATIVE_RUNTIME_CONFIG, + SIGNAL_CLI_CONFIG, + SLACK_DESKTOP_IMPORT_CONFIG, +} from "../core/config.js"; import { CuedDatabase } from "../db/database.js"; +import { WHATSAPP_HELPER_CONFIG } from "../platforms/whatsapp/helper/pair.js"; import { buildOnboardingSnapshot } from "./onboarding.js"; describe("onboarding snapshot", () => { const tempDirs: string[] = []; + const originalIMessageDbPath = IMESSAGE_CONFIG.chatDbPath; + const originalNativeRepoRoot = NATIVE_RUNTIME_CONFIG.repoRoot; + const originalSignalRepoRoot = SIGNAL_CLI_CONFIG.repoRoot; + const originalSlackAppBinary = SLACK_DESKTOP_IMPORT_CONFIG.appBinary; + const originalWhatsAppHelperBinary = WHATSAPP_HELPER_CONFIG.helperBinary; afterEach(() => { - delete process.env.CUED_CONTACTS_NATIVE_BINARY; - delete process.env.CUED_IMESSAGE_DB_PATH; - delete process.env.CUED_SLACK_APP_BINARY; - delete process.env.CUED_APP_PATH; - delete process.env.CUED_WHATSAPP_HELPER_BINARY; + WHATSAPP_HELPER_CONFIG.helperBinary = originalWhatsAppHelperBinary; + IMESSAGE_CONFIG.chatDbPath = originalIMessageDbPath; + NATIVE_RUNTIME_CONFIG.repoRoot = originalNativeRepoRoot; + SIGNAL_CLI_CONFIG.repoRoot = originalSignalRepoRoot; + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = originalSlackAppBinary; while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -36,36 +48,50 @@ describe("onboarding snapshot", () => { return db; } - function createPackagedSignalHelper(version = "0.12.9"): string { - const appPath = join(createTempDir("cued-app-"), "Cued.app"); + function installSignalHelper(version = "0.12.9"): void { + const repoRoot = createTempDir("cued-signal-repo-"); + SIGNAL_CLI_CONFIG.repoRoot = repoRoot; const helperPath = join( - appPath, - "Contents", - "Resources", + repoRoot, + "native", "helpers", "signal-cli", + ".build", + "cued-signal-cli", "cued-signal-cli", ); mkdirSync(join(helperPath, ".."), { recursive: true }); writeFileSync(helperPath, `#!/bin/sh\necho "signal-cli ${version}"\n`); chmodSync(helperPath, 0o755); - return appPath; + } + + function installNativeBinary(contents: string): string { + const repoRoot = createTempDir("cued-native-repo-"); + NATIVE_RUNTIME_CONFIG.repoRoot = repoRoot; + const nativeBinaryPath = join( + repoRoot, + "native", + "macos", + "CuedNative", + ".build", + "release", + "CuedNative", + ); + mkdirSync(join(nativeBinaryPath, ".."), { recursive: true }); + writeFileSync(nativeBinaryPath, contents); + chmodSync(nativeBinaryPath, 0o755); + return nativeBinaryPath; } it("builds one snapshot with fresh permissions and onboarding integrations", async () => { - const nativeBinaryDir = createTempDir("cued-native-binary-"); - const nativeBinaryPath = join(nativeBinaryDir, "CuedNative"); - writeFileSync( - nativeBinaryPath, + installNativeBinary( '#!/bin/sh\nif [ "$1" = "contacts" ] && [ "$2" = "status" ]; then\n echo \'{"status":"authorized"}\'\n exit 0\nfi\nexit 1\n', ); - chmodSync(nativeBinaryPath, 0o755); - process.env.CUED_CONTACTS_NATIVE_BINARY = nativeBinaryPath; - process.env.CUED_IMESSAGE_DB_PATH = join(createTempDir("cued-imessage-"), "missing.db"); - process.env.CUED_SLACK_APP_BINARY = join(createTempDir("cued-no-slack-app-"), "Slack"); - process.env.CUED_APP_PATH = createPackagedSignalHelper(); - process.env.CUED_WHATSAPP_HELPER_BINARY = join( + IMESSAGE_CONFIG.chatDbPath = join(createTempDir("cued-imessage-"), "missing.db"); + SLACK_DESKTOP_IMPORT_CONFIG.appBinary = join(createTempDir("cued-no-slack-app-"), "Slack"); + installSignalHelper(); + WHATSAPP_HELPER_CONFIG.helperBinary = join( createTempDir("cued-no-whatsapp-helper-"), "cued-whatsapp-helper", ); diff --git a/src/runtime/perf/run.ts b/src/runtime/perf/run.ts index 37629769..fe575884 100644 --- a/src/runtime/perf/run.ts +++ b/src/runtime/perf/run.ts @@ -762,7 +762,6 @@ async function main(): Promise { path: imessageFixture.dbPath, lastRowId: 1, limit: 2500, - env: { CUED_IMESSAGE_DB_PATH: imessageFixture.dbPath }, repoRoot: imessageFixture.dir, }); }), diff --git a/src/runtime/projection/worker.test.ts b/src/runtime/projection/worker.test.ts index 940c70d8..c8360c00 100644 --- a/src/runtime/projection/worker.test.ts +++ b/src/runtime/projection/worker.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { sql } from "drizzle-orm"; @@ -6,9 +6,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; describe("projection worker", () => { const tempDirs: string[] = []; + const originalArgv = process.argv.slice(); afterEach(() => { - vi.unstubAllEnvs(); + process.argv.splice(0, process.argv.length, ...originalArgv); vi.resetModules(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -24,11 +25,15 @@ describe("projection worker", () => { return dir; } + function useRuntimeConfig(home: string): void { + const configPath = join(home, "config.json"); + writeFileSync(configPath, JSON.stringify({ home, dbPath: join(home, "local.db") }), "utf8"); + process.argv.splice(0, process.argv.length, ...originalArgv, "--config", configPath); + } + it("fails instead of reporting success when the run claim was reclaimed", async () => { const home = createTempHome(); - vi.stubEnv("CUED_HOME", home); - vi.stubEnv("CUED_DB_PATH", join(home, "local.db")); - vi.stubEnv("CUED_PROJECTION_BATCH_SIZE", "10"); + useRuntimeConfig(home); vi.resetModules(); const { openCuedDatabase } = await import("../../db/database.js"); diff --git a/src/runtime/projection/worker.ts b/src/runtime/projection/worker.ts index ae7b27b5..8bead1a8 100644 --- a/src/runtime/projection/worker.ts +++ b/src/runtime/projection/worker.ts @@ -25,17 +25,25 @@ export type ProjectionWorkerMessage = | { ok: true; result: ProjectionWorkerSuccess } | { ok: false; error: string }; +type ProjectionWorkerInvocation = { + run: QueuedSyncRun; + projectionBatchSize?: number; +}; + function now(): number { return Date.now(); } -export async function runProjectionWorker(run: QueuedSyncRun): Promise { +export async function runProjectionWorker( + run: QueuedSyncRun, + options: { projectionBatchSize?: number } = {}, +): Promise { const db = openCuedDatabase(); const projectionStartedAt = now(); const runClaim = { ownerId: run.owner_id, attempt: run.attempt }; try { const projectionDetails = parseProjectionRunDetails(run.details_json); - const projectionBatchSize = Number(process.env.CUED_PROJECTION_BATCH_SIZE || "25"); + const projectionBatchSize = options.projectionBatchSize ?? 25; const projected = run.run_type === "rebuild" ? rebuildProjectedState(db, { limit: projectionBatchSize }) @@ -110,14 +118,16 @@ export async function runProjectionWorker(run: QueuedSyncRun): Promise { - const runJson = process.env.CUED_PROJECTION_WORKER_RUN; - if (!runJson) { - throw new Error("CUED_PROJECTION_WORKER_RUN is required"); +export async function runProjectionWorkerFromStdin(): Promise { + const raw = await readText(process.stdin); + if (!raw.trim()) { + throw new Error("Projection worker invocation JSON is required on stdin"); } - const run = JSON.parse(runJson) as QueuedSyncRun; + const invocation = JSON.parse(raw) as ProjectionWorkerInvocation; try { - const result = await runProjectionWorker(run); + const result = await runProjectionWorker(invocation.run, { + projectionBatchSize: invocation.projectionBatchSize, + }); writeWorkerMessage({ ok: true, result }); } catch (error) { writeWorkerMessage({ @@ -131,3 +141,11 @@ export async function runProjectionWorkerFromEnv(): Promise { function writeWorkerMessage(message: ProjectionWorkerMessage): void { process.stdout.write(`${JSON.stringify(message)}\n`); } + +async function readText(input: AsyncIterable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of input) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/src/runtime/updater/service.test.ts b/src/runtime/updater/service.test.ts index d8484a1b..a71177e8 100644 --- a/src/runtime/updater/service.test.ts +++ b/src/runtime/updater/service.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { APP_METADATA_CONFIG } from "../../core/app-metadata.js"; import { CuedDatabase } from "../../db/database.js"; import { checkForUpdates, @@ -16,9 +17,12 @@ import { describe("updater service", () => { const tempDirs: string[] = []; + const originalAppVersion = APP_METADATA_CONFIG.version; + const originalReleaseChannel = APP_METADATA_CONFIG.releaseChannel; afterEach(() => { - vi.unstubAllEnvs(); + APP_METADATA_CONFIG.version = originalAppVersion; + APP_METADATA_CONFIG.releaseChannel = originalReleaseChannel; while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -74,8 +78,8 @@ describe("updater service", () => { }); it("reuses cached update metadata on scheduled 304 responses", async () => { - vi.stubEnv("CUED_APP_VERSION", "0.1.0"); - vi.stubEnv("CUED_RELEASE_CHANNEL", "stable"); + APP_METADATA_CONFIG.version = "0.1.0"; + APP_METADATA_CONFIG.releaseChannel = "stable"; const db = createDb(); db.setUpdateReleaseState({ checkedAt: 1, @@ -107,8 +111,8 @@ describe("updater service", () => { }); it("does not send stale etags on forced update checks", async () => { - vi.stubEnv("CUED_APP_VERSION", "0.1.0"); - vi.stubEnv("CUED_RELEASE_CHANNEL", "stable"); + APP_METADATA_CONFIG.version = "0.1.0"; + APP_METADATA_CONFIG.releaseChannel = "stable"; const db = createDb(); db.setUpdateReleaseState({ checkedAt: 1, @@ -156,8 +160,8 @@ describe("updater service", () => { }); it("does not reuse fresh cache when the release channel changes", async () => { - vi.stubEnv("CUED_APP_VERSION", "0.1.0"); - vi.stubEnv("CUED_RELEASE_CHANNEL", "internal"); + APP_METADATA_CONFIG.version = "0.1.0"; + APP_METADATA_CONFIG.releaseChannel = "internal"; const db = createDb(); db.setUpdateReleaseState({ checkedAt: Date.now(), @@ -205,8 +209,8 @@ describe("updater service", () => { }); it("does not reuse fresh cache when the current app version changes", async () => { - vi.stubEnv("CUED_APP_VERSION", "0.2.0"); - vi.stubEnv("CUED_RELEASE_CHANNEL", "stable"); + APP_METADATA_CONFIG.version = "0.2.0"; + APP_METADATA_CONFIG.releaseChannel = "stable"; const db = createDb(); db.setUpdateReleaseState({ checkedAt: Date.now(), @@ -240,8 +244,8 @@ describe("updater service", () => { }); it("skips remote checks on dev channel", async () => { - vi.stubEnv("CUED_APP_VERSION", "0.1.3"); - vi.stubEnv("CUED_RELEASE_CHANNEL", "dev"); + APP_METADATA_CONFIG.version = "0.1.3"; + APP_METADATA_CONFIG.releaseChannel = "dev"; const db = createDb(); const fetchMock = vi.fn(); const fetchImpl = fetchMock as unknown as typeof fetch; diff --git a/src/runtime/updater/service.ts b/src/runtime/updater/service.ts index 74bc8ba6..d4a45a41 100644 --- a/src/runtime/updater/service.ts +++ b/src/runtime/updater/service.ts @@ -23,6 +23,7 @@ import { CUED_UPDATE_DOWNLOADS_DIR, CUED_UPDATE_ROLLBACK_DIR, ensureCuedDirs, + UPDATE_RELEASE_CONFIG, } from "../../core/config.js"; import { createLogger } from "../../core/logging.js"; import { @@ -57,8 +58,8 @@ import type { const updaterLogger = createLogger("updater"); const RELEASE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; -const RELEASE_REPO = process.env.CUED_RELEASE_REPO ?? "Cue-d/cued"; -const RELEASE_API_BASE = process.env.CUED_RELEASE_API_BASE ?? "https://api.github.com"; +const RELEASE_REPO = UPDATE_RELEASE_CONFIG.repo; +const RELEASE_API_BASE = UPDATE_RELEASE_CONFIG.apiBase; const RELEASE_ASSET_NAME = "cued-macos-arm64.tar.gz"; const UPDATE_SHUTDOWN_TIMEOUT_MS = 45_000; const UPDATE_HEALTH_TIMEOUT_MS = 90_000; @@ -521,10 +522,7 @@ function backupDatabaseSnapshot(sourceDbPath: string, destinationPath: string): function runPreflight(stagedAppPath: string, preflightDbPath: string): void { const cliPath = join(stagedAppPath, "Contents", "Resources", "cued-cli"); execFileSync(cliPath, ["update", "preflight", "--db-path", preflightDbPath], { - env: { - ...process.env, - CUED_APP_PATH: stagedAppPath, - }, + env: process.env, stdio: "ignore", }); } diff --git a/src/skills/install.test.ts b/src/skills/install.test.ts index b93d85cc..dbebc572 100644 --- a/src/skills/install.test.ts +++ b/src/skills/install.test.ts @@ -15,6 +15,7 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +import { MACOS_APP_CONFIG } from "../core/config.js"; import { getGlobalCuedSkillStatus, installGlobalCuedSkill, @@ -25,11 +26,11 @@ import { describe("cued skill installer", () => { const tempDirs: string[] = []; const originalHome = process.env.HOME; - const originalAppPath = process.env.CUED_APP_PATH; + const originalAppPath = MACOS_APP_CONFIG.currentAppPath; afterEach(() => { process.env.HOME = originalHome; - process.env.CUED_APP_PATH = originalAppPath; + MACOS_APP_CONFIG.currentAppPath = originalAppPath; vi.clearAllMocks(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); @@ -98,7 +99,7 @@ describe("cued skill installer", () => { it("resolves the bundled cued skill from the installed app resources", () => { setTempHome(); const appPath = createAppBundle(createTempDir("cued-skill-app-")); - process.env.CUED_APP_PATH = appPath; + MACOS_APP_CONFIG.currentAppPath = appPath; expect(resolveCuedSkillSourcePath()).toBe( join(appPath, "Contents", "Resources", "skills", "cued"), @@ -109,7 +110,7 @@ describe("cued skill installer", () => { setTempHome(); const appPath = createAppBundle(createTempDir("cued-skill-app-")); const npxPath = createMockNpx(createTempDir("cued-skill-npx-")); - process.env.CUED_APP_PATH = appPath; + MACOS_APP_CONFIG.currentAppPath = appPath; execFileSyncMock.mockImplementation( (command: string, args?: string[], options?: { env?: Record }) => { @@ -155,7 +156,7 @@ describe("cued skill installer", () => { setTempHome(); const appPath = createAppBundle(createTempDir("cued-skill-app-")); const npxPath = createMockNpx(createTempDir("cued-skill-npx-")); - process.env.CUED_APP_PATH = appPath; + MACOS_APP_CONFIG.currentAppPath = appPath; execFileSyncMock.mockImplementation((command: string, args?: string[]) => { if (command === "/bin/zsh") { @@ -182,7 +183,7 @@ describe("cued skill installer", () => { it("prefers the newest NVM npx version by semantic version order", () => { const homeDir = setTempHome(); const appPath = createAppBundle(createTempDir("cued-skill-app-")); - process.env.CUED_APP_PATH = appPath; + MACOS_APP_CONFIG.currentAppPath = appPath; createFile(join(homeDir, ".nvm", "versions", "node", "v20.9.0", "bin", "npx")); createFile(join(homeDir, ".nvm", "versions", "node", "v20.11.0", "bin", "npx")); diff --git a/src/telemetry/client.test.ts b/src/telemetry/client.test.ts index 9049ed66..bd7e36b1 100644 --- a/src/telemetry/client.test.ts +++ b/src/telemetry/client.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { openCuedDatabase } from "../db/database.js"; import { buildTelemetryEnvelope, @@ -166,7 +166,7 @@ describe("telemetry client", () => { } }); - it("skips sends under Vitest unless an endpoint is explicitly configured", async () => { + it("skips sends under Vitest by default", async () => { const db = openTempDb(); try { const result = await sendTelemetryEvent( @@ -186,7 +186,6 @@ describe("telemetry client", () => { }); it("aborts telemetry sends that exceed the timeout", async () => { - vi.stubEnv("CUED_TELEMETRY_ENDPOINT", "https://example.invalid/telemetry"); const db = openTempDb(); try { await expect( @@ -206,11 +205,11 @@ describe("telemetry client", () => { reject(new DOMException("aborted", "AbortError")), ); })) as typeof fetch, + allowInTests: true, }, ), ).rejects.toThrow(/aborted/i); } finally { - vi.unstubAllEnvs(); db.close(); } }); diff --git a/src/telemetry/client.ts b/src/telemetry/client.ts index 68c77578..fc7e0b55 100644 --- a/src/telemetry/client.ts +++ b/src/telemetry/client.ts @@ -85,7 +85,7 @@ function getOrCreateSetting(db: CuedDatabase, key: string, createValue: () => st } export function isTelemetryEnabled(db: CuedDatabase): boolean { - if (process.env.VITEST_WORKER_ID && !process.env.CUED_TELEMETRY_ENDPOINT) { + if (process.env.VITEST_WORKER_ID) { return false; } return db.getAppSetting(TELEMETRY_SETTING_KEYS.enabled)?.value !== "0"; @@ -146,9 +146,12 @@ export async function sendTelemetryEvent( db: CuedDatabase, eventName: TelemetryEventName, properties: TelemetryProperties = {}, - options: { fetchImpl?: typeof fetch } = {}, + options: { fetchImpl?: typeof fetch; allowInTests?: boolean } = {}, ): Promise<{ sent: boolean; skipped: boolean; status?: number }> { - if (!isTelemetryEnabled(db)) { + if (!options.allowInTests && process.env.VITEST_WORKER_ID) { + return { sent: false, skipped: true }; + } + if (db.getAppSetting(TELEMETRY_SETTING_KEYS.enabled)?.value === "0") { return { sent: false, skipped: true }; } diff --git a/src/telemetry/events.ts b/src/telemetry/events.ts index d19d106f..c93a9362 100644 --- a/src/telemetry/events.ts +++ b/src/telemetry/events.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; +import { TELEMETRY_CONFIG } from "../core/config.js"; -export const TELEMETRY_ENDPOINT = - process.env.CUED_TELEMETRY_ENDPOINT ?? "https://cued.so/api/telemetry/events"; +export const TELEMETRY_ENDPOINT = TELEMETRY_CONFIG.endpoint; export const TELEMETRY_SETTING_KEYS = { enabled: "telemetry_enabled", From bc68495d8821de4185fc7199160f6ff3d6460b97 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 18:57:59 -0400 Subject: [PATCH 3/7] Centralize helper binary overrides --- src/core/config.ts | 8 ++++++++ src/platforms/core/state/integration-state.test.ts | 4 ++-- src/platforms/slack/helper/binary.test.ts | 2 +- src/platforms/slack/helper/binary.ts | 5 +---- src/platforms/whatsapp/helper/pair.test.ts | 2 +- src/platforms/whatsapp/helper/pair.ts | 6 +----- src/runtime/onboarding.test.ts | 2 +- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/core/config.ts b/src/core/config.ts index adab751e..b68228cc 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -247,6 +247,12 @@ export const CONFIG = { remoteDebuggingPort: 9222, timeoutMs: 20_000, }, + slackHelper: { + helperBinary: null as string | null, + }, + whatsappHelper: { + helperBinary: null as string | null, + }, signalCli: { repoRoot: REPO_ROOT, }, @@ -273,6 +279,8 @@ export const LINKEDIN_SYNC_CONFIG = CONFIG.linkedinSync; export const DISCORD_SYNC_CONFIG = CONFIG.discordSync; export const SLACK_SYNC_CONFIG = CONFIG.slackSync; export const SLACK_DESKTOP_IMPORT_CONFIG = CONFIG.slackDesktopImport; +export const SLACK_HELPER_CONFIG = CONFIG.slackHelper; +export const WHATSAPP_HELPER_CONFIG = CONFIG.whatsappHelper; export const SIGNAL_CLI_CONFIG = CONFIG.signalCli; export const AUTH_RUNTIME_CONFIG = CONFIG.authRuntime; diff --git a/src/platforms/core/state/integration-state.test.ts b/src/platforms/core/state/integration-state.test.ts index 185bbc8a..8a70b1bf 100644 --- a/src/platforms/core/state/integration-state.test.ts +++ b/src/platforms/core/state/integration-state.test.ts @@ -8,6 +8,8 @@ import { PLATFORM_RUNTIME_CONFIG, SIGNAL_CLI_CONFIG, SLACK_DESKTOP_IMPORT_CONFIG, + SLACK_HELPER_CONFIG, + WHATSAPP_HELPER_CONFIG, } from "../../../core/config.js"; import { resolveHostOS } from "../../../core/platform-capabilities.js"; import { CuedDatabase } from "../../../db/database.js"; @@ -18,8 +20,6 @@ import { } from "../../discord/sync/events.js"; import { importLinkedInStoredAuth } from "../../linkedin/auth/keychain-import.js"; import { storeSlackSession } from "../../slack/auth/session-store.js"; -import { SLACK_HELPER_CONFIG } from "../../slack/helper/binary.js"; -import { WHATSAPP_HELPER_CONFIG } from "../../whatsapp/helper/pair.js"; import { startQrNativeAuthSession } from "../auth/qr-native.js"; import { refreshLocalIntegrationStates } from "./local-refresh.js"; import { diff --git a/src/platforms/slack/helper/binary.test.ts b/src/platforms/slack/helper/binary.test.ts index 0b038dfe..c6c223c1 100644 --- a/src/platforms/slack/helper/binary.test.ts +++ b/src/platforms/slack/helper/binary.test.ts @@ -2,12 +2,12 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { SLACK_HELPER_CONFIG } from "../../../core/config.js"; import { getSlackHelperBinaryCandidates, inspectSlackHelper, readSlackHelperStatus, resolveSlackHelperBinary, - SLACK_HELPER_CONFIG, } from "./binary.js"; describe("slack helper binary", () => { diff --git a/src/platforms/slack/helper/binary.ts b/src/platforms/slack/helper/binary.ts index 5541565d..1ee45547 100644 --- a/src/platforms/slack/helper/binary.ts +++ b/src/platforms/slack/helper/binary.ts @@ -3,15 +3,12 @@ import { existsSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { SLACK_HELPER_CONFIG } from "../../../core/config.js"; const execFileAsync = promisify(execFile); const SLACK_HELPER_BINARY_NAME = "cued-slack-helper"; const SUPPORTED_SLACK_HELPER_PROTOCOL_VERSION = 1; -export const SLACK_HELPER_CONFIG = { - helperBinary: null as string | null, -}; - function resolveRepoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); } diff --git a/src/platforms/whatsapp/helper/pair.test.ts b/src/platforms/whatsapp/helper/pair.test.ts index 03e4d52d..2e29d733 100644 --- a/src/platforms/whatsapp/helper/pair.test.ts +++ b/src/platforms/whatsapp/helper/pair.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { WHATSAPP_HELPER_CONFIG } from "../../../core/config.js"; const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn(), @@ -22,7 +23,6 @@ import { readWhatsAppHelperStatus, resolveWhatsAppHelperBinary, startWhatsAppPairSession, - WHATSAPP_HELPER_CONFIG, } from "./pair.js"; class MockPairChild extends EventEmitter { diff --git a/src/platforms/whatsapp/helper/pair.ts b/src/platforms/whatsapp/helper/pair.ts index b8e6f3bf..deaf5c28 100644 --- a/src/platforms/whatsapp/helper/pair.ts +++ b/src/platforms/whatsapp/helper/pair.ts @@ -3,17 +3,13 @@ import { existsSync, mkdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { ensureCuedDirs } from "../../../core/config.js"; +import { ensureCuedDirs, WHATSAPP_HELPER_CONFIG } from "../../../core/config.js"; import { validateIntegrationAccountKey } from "../../core/account-keys.js"; import { getWhatsAppStoreRoot } from "../../core/runtime-paths.js"; import type { WhatsAppHelperEventEnvelope } from "../types.js"; const execFileAsync = promisify(execFile); -export const WHATSAPP_HELPER_CONFIG = { - helperBinary: null as string | null, -}; - function resolveRepoRoot(): string { return resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); } diff --git a/src/runtime/onboarding.test.ts b/src/runtime/onboarding.test.ts index c1815840..7a77d852 100644 --- a/src/runtime/onboarding.test.ts +++ b/src/runtime/onboarding.test.ts @@ -7,9 +7,9 @@ import { NATIVE_RUNTIME_CONFIG, SIGNAL_CLI_CONFIG, SLACK_DESKTOP_IMPORT_CONFIG, + WHATSAPP_HELPER_CONFIG, } from "../core/config.js"; import { CuedDatabase } from "../db/database.js"; -import { WHATSAPP_HELPER_CONFIG } from "../platforms/whatsapp/helper/pair.js"; import { buildOnboardingSnapshot } from "./onboarding.js"; describe("onboarding snapshot", () => { From 5a34b684584a3ff5cd8163b04bb456f28ea46fa2 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 19:10:40 -0400 Subject: [PATCH 4/7] Use configured iMessage database for watcher --- src/runtime/daemon/server.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/runtime/daemon/server.ts b/src/runtime/daemon/server.ts index f9c5ecbe..580dff89 100644 --- a/src/runtime/daemon/server.ts +++ b/src/runtime/daemon/server.ts @@ -58,7 +58,6 @@ import { } from "../../platforms/discord/sync/events.js"; import { isDiscordDmChannel } from "../../platforms/discord/types.js"; import { GmailClient } from "../../platforms/gmail/api/client.js"; -import { DEFAULT_CHAT_DB_PATH } from "../../platforms/imessage/reader.js"; import { loadLinkedInSessionSecret } from "../../platforms/linkedin/auth/session-store.js"; import { buildLinkedInRawEventsFromRealtimeEnvelope } from "../../platforms/linkedin/realtime/events.js"; import { @@ -985,9 +984,10 @@ function startIMessageWatcher( _db: ReturnType, queueSync: (platform: AdapterPlatform, accountKey: string, trigger: string) => void, ): FSWatcher | ChildProcess | null { + const chatDbPath = IMESSAGE_CONFIG.chatDbPath; const nativeBinary = resolveMacOSNativeBinary(); if (nativeBinary) { - const child = spawn(nativeBinary, ["imessage", "watch", "--db-path", DEFAULT_CHAT_DB_PATH], { + const child = spawn(nativeBinary, ["imessage", "watch", "--db-path", chatDbPath], { stdio: ["ignore", "pipe", "pipe"], env: process.env, }); @@ -1023,12 +1023,9 @@ function startIMessageWatcher( } try { - const targetDir = dirname(DEFAULT_CHAT_DB_PATH); - const watchedNames = new Set([ - basename(DEFAULT_CHAT_DB_PATH), - `${basename(DEFAULT_CHAT_DB_PATH)}-wal`, - `${basename(DEFAULT_CHAT_DB_PATH)}-shm`, - ]); + const targetDir = dirname(chatDbPath); + const chatDbName = basename(chatDbPath); + const watchedNames = new Set([chatDbName, `${chatDbName}-wal`, `${chatDbName}-shm`]); return watch(targetDir, (_eventType, filename) => { if (!filename) { From 94bbf784aceab7bc6856544273d5690eac477f51 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 19:31:10 -0400 Subject: [PATCH 5/7] Use configured iMessage database in doctor --- src/runtime/doctor-status.test.ts | 40 +++++++++++++++++++++++++++++-- src/runtime/doctor.ts | 17 +++++++------ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/runtime/doctor-status.test.ts b/src/runtime/doctor-status.test.ts index 62ee6b9e..1d4df66e 100644 --- a/src/runtime/doctor-status.test.ts +++ b/src/runtime/doctor-status.test.ts @@ -1,6 +1,6 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; const { execFileSyncMock } = vi.hoisted(() => ({ @@ -15,7 +15,7 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); -import { NATIVE_RUNTIME_CONFIG } from "../core/config.js"; +import { IMESSAGE_CONFIG, NATIVE_RUNTIME_CONFIG } from "../core/config.js"; import { CuedDatabase } from "../db/database.js"; import { buildPermissionStatus } from "./doctor.js"; @@ -23,9 +23,11 @@ const itDarwin = process.platform === "darwin" ? it : it.skip; describe("permission status modes", () => { const tempDirs: string[] = []; + const originalIMessageDbPath = IMESSAGE_CONFIG.chatDbPath; const originalNativeRepoRoot = NATIVE_RUNTIME_CONFIG.repoRoot; afterEach(() => { + IMESSAGE_CONFIG.chatDbPath = originalIMessageDbPath; NATIVE_RUNTIME_CONFIG.repoRoot = originalNativeRepoRoot; vi.clearAllMocks(); while (tempDirs.length > 0) { @@ -90,4 +92,38 @@ describe("permission status modes", () => { db.close(); }); + + itDarwin("uses the configured iMessage database path for native permission checks", async () => { + const db = createDb(); + const nativeBinary = installNativeBinary(); + const configuredChatDbPath = join( + mkdtempSync(join(tmpdir(), "cued-doctor-imessage-config-")), + "custom-chat.db", + ); + tempDirs.push(dirname(configuredChatDbPath)); + IMESSAGE_CONFIG.chatDbPath = configuredChatDbPath; + + execFileSyncMock.mockImplementation((command: string, args?: string[]) => { + if (command === nativeBinary && args?.[0] === "contacts") { + return '{"status":"authorized"}'; + } + if (command === nativeBinary && args?.[0] === "imessage") { + return ""; + } + throw new Error(`unexpected command: ${command}`); + }); + + await buildPermissionStatus({ + mode: "passive", + db, + }); + + expect(execFileSyncMock).toHaveBeenCalledWith( + nativeBinary, + ["imessage", "dump", "--db-path", configuredChatDbPath, "--after-rowid", "0", "--limit", "1"], + expect.objectContaining({ encoding: "utf8" }), + ); + + db.close(); + }); }); diff --git a/src/runtime/doctor.ts b/src/runtime/doctor.ts index 2343c091..0c0eedb4 100644 --- a/src/runtime/doctor.ts +++ b/src/runtime/doctor.ts @@ -1,11 +1,12 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; +import { IMESSAGE_CONFIG } from "../core/config.js"; import type { CuedDatabase } from "../db/database.js"; import { listAdapterPlatforms } from "../platforms/core/registry.js"; import { buildIntegrationStatus, listIntegrationStates } from "../platforms/core/state/status.js"; import { resolveGoogleOAuthClientFile } from "../platforms/gmail/oauth/client.js"; -import { DEFAULT_CHAT_DB_PATH, IMessageReader } from "../platforms/imessage/reader.js"; +import { IMessageReader } from "../platforms/imessage/reader.js"; import { getSignalConfigDir, isSignalCliVersionSupported, @@ -158,18 +159,19 @@ export async function buildPermissionStatus( } function tryReadMessagesDatabase(): DoctorCheck { - if (!existsSync(DEFAULT_CHAT_DB_PATH)) { + const chatDbPath = IMESSAGE_CONFIG.chatDbPath; + if (!existsSync(chatDbPath)) { return { name: "messages_database", status: "error", summary: "Messages database was not found", - details: { path: DEFAULT_CHAT_DB_PATH }, + details: { path: chatDbPath }, remediation: "Open Messages once on this Mac, then rerun doctor.", }; } try { - const reader = new IMessageReader(DEFAULT_CHAT_DB_PATH); + const reader = new IMessageReader(chatDbPath); try { const maxRowId = reader.getMaxMessageRowid(); return { @@ -177,7 +179,7 @@ function tryReadMessagesDatabase(): DoctorCheck { status: "ok", summary: "Messages database is readable", details: { - path: DEFAULT_CHAT_DB_PATH, + path: chatDbPath, maxRowId, }, }; @@ -190,7 +192,7 @@ function tryReadMessagesDatabase(): DoctorCheck { status: "error", summary: "Messages database is not readable from the current process", details: { - path: DEFAULT_CHAT_DB_PATH, + path: chatDbPath, error: error instanceof Error ? error.message : String(error), }, remediation: @@ -200,6 +202,7 @@ function tryReadMessagesDatabase(): DoctorCheck { } function getMessagesNativeHelperCheck(): DoctorCheck { + const chatDbPath = IMESSAGE_CONFIG.chatDbPath; const nativeBinary = resolveMacOSNativeBinary(); if (!nativeBinary) { return { @@ -215,7 +218,7 @@ function getMessagesNativeHelperCheck(): DoctorCheck { try { execFileSync( nativeBinary, - ["imessage", "dump", "--db-path", DEFAULT_CHAT_DB_PATH, "--after-rowid", "0", "--limit", "1"], + ["imessage", "dump", "--db-path", chatDbPath, "--after-rowid", "0", "--limit", "1"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], From 61d5669167c5d6eaf9069d6357e8f572c4dddc90 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 19:49:26 -0400 Subject: [PATCH 6/7] Align runtime config invocation cleanup --- src/core/config.test.ts | 43 +++++++++++++++++++++++++---- src/core/config.ts | 2 +- src/platforms/core/invocation.ts | 1 - src/platforms/signal/sync/worker.ts | 1 - 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 62998695..ec6e7ffd 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,15 +1,38 @@ -import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const originalArgv = process.argv.slice(); +const tempDirs: string[] = []; + +afterEach(() => { + process.argv.splice(0, process.argv.length, ...originalArgv); + vi.resetModules(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +async function importConfig(args = originalArgv) { + process.argv.splice(0, process.argv.length, ...args); + vi.resetModules(); + return import("./config.js"); +} describe("config path resolution", () => { it("uses configured home for runtime paths", async () => { - const config = await import("./config.js"); + const config = await importConfig(); expect(config.resolveCuedHome({ home: "/tmp/cued-home" })).toBe("/tmp/cued-home"); expect(config.resolveCuedDbPath({ home: "/tmp/cued-home" })).toBe("/tmp/cued-home/local.db"); }); it("derives the cued home from dbPath when only the db path is configured", async () => { - const config = await import("./config.js"); + const config = await importConfig(); expect(config.resolveCuedHome({ dbPath: "/tmp/cued-db/local.db" })).toBe("/tmp/cued-db"); expect(config.resolveCuedDbPath({ dbPath: "/tmp/cued-db/local.db" })).toBe( @@ -17,8 +40,18 @@ describe("config path resolution", () => { ); }); + it("uses an explicit missing config parent instead of the real home", async () => { + const dir = mkdtempSync(join(tmpdir(), "cued-missing-config-")); + tempDirs.push(dir); + const configPath = join(dir, "config.json"); + const config = await importConfig(["node", "cued", "--config", configPath]); + + expect(config.resolveCuedHome()).toBe(dir); + expect(config.resolveCuedDbPath()).toBe(join(dir, "local.db")); + }); + it("strips runtime config args before command parsing", async () => { - const config = await import("./config.js"); + const config = await importConfig(); expect(config.stripRuntimeConfigArgs(["--config", "/tmp/cued.json", "daemon"])).toEqual([ "daemon", @@ -29,7 +62,7 @@ describe("config path resolution", () => { }); it("resolves packaged Chromium beside the bundled runtime", async () => { - const config = await import("./config.js"); + const config = await importConfig(); const existingPaths = new Set([ "/Applications/Cued.app/Contents/Resources/runtime/chromium/chrome/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", ]); diff --git a/src/core/config.ts b/src/core/config.ts index b68228cc..c7f3f082 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -54,7 +54,7 @@ function readRuntimeConfig(args = process.argv.slice(2)): RuntimeConfigFile { } if (!existsSync(path)) { if (explicit) { - throw new Error(`Cued config file does not exist: ${path}`); + return { home: dirname(resolve(path)) }; } return {}; } diff --git a/src/platforms/core/invocation.ts b/src/platforms/core/invocation.ts index ecee2d2d..39038f44 100644 --- a/src/platforms/core/invocation.ts +++ b/src/platforms/core/invocation.ts @@ -7,7 +7,6 @@ export type AdapterInvocation = { syncProofs?: unknown; lastSyncAt?: number; syncToken?: string | null; - signalAccount?: string; imessageLastRowId?: number; whatsappSource?: "desktop_db"; whatsappDesktopSourcePath?: string; diff --git a/src/platforms/signal/sync/worker.ts b/src/platforms/signal/sync/worker.ts index f6cd74e8..1d9955ec 100644 --- a/src/platforms/signal/sync/worker.ts +++ b/src/platforms/signal/sync/worker.ts @@ -6,7 +6,6 @@ async function main(): Promise { const invocation = await readAdapterInvocation(); const bundle = await buildSignalSyncBundle({ accountKey: invocation.accountKey, - account: invocation.signalAccount, lastSyncAt: invocation.lastSyncAt, }); process.stdout.write(JSON.stringify({ ok: true, bundle })); From 6796a1dee43bc85e6da484ab41a07f1028a868a6 Mon Sep 17 00:00:00 2001 From: Soham Bafana Date: Mon, 25 May 2026 20:13:45 -0400 Subject: [PATCH 7/7] Align malformed runtime config fallback --- .../CuedNativeTests/RuntimeSupportTests.swift | 19 +++++++++++++++++++ src/core/config.test.ts | 13 ++++++++++++- src/core/config.ts | 8 ++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift b/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift index fe279d19..50a2084e 100644 --- a/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift +++ b/native/macos/CuedNative/Tests/CuedNativeTests/RuntimeSupportTests.swift @@ -70,6 +70,25 @@ final class RuntimeSupportTests: XCTestCase { ) } + func testExplicitMalformedConfigDoesNotFallBackToRealHome() throws { + let configPath = try writeConfig("{") + + XCTAssertEqual( + configuredCuedHomePath( + arguments: ["Cued", "--config", configPath], + homeDirectory: "/Users/test" + ), + URL(fileURLWithPath: configPath).deletingLastPathComponent().path + ) + XCTAssertEqual( + configuredCuedDBPath( + arguments: ["Cued", "--config", configPath], + homeDirectory: "/Users/test" + ), + "\(URL(fileURLWithPath: configPath).deletingLastPathComponent().path)/local.db" + ) + } + func testPermissionRelaunchSetupIntentPathUsesConfiguredCuedHome() throws { let homeConfigPath = try writeConfig(#"{"home":" /tmp/cued-home "}"#) XCTAssertEqual( diff --git a/src/core/config.test.ts b/src/core/config.test.ts index ec6e7ffd..0308aac1 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -50,6 +50,17 @@ describe("config path resolution", () => { expect(config.resolveCuedDbPath()).toBe(join(dir, "local.db")); }); + it("uses an explicit malformed config parent instead of the real home", async () => { + const dir = mkdtempSync(join(tmpdir(), "cued-malformed-config-")); + tempDirs.push(dir); + const configPath = join(dir, "config.json"); + writeFileSync(configPath, "{", "utf8"); + const config = await importConfig(["node", "cued", "--config", configPath]); + + expect(config.resolveCuedHome()).toBe(dir); + expect(config.resolveCuedDbPath()).toBe(join(dir, "local.db")); + }); + it("strips runtime config args before command parsing", async () => { const config = await importConfig(); diff --git a/src/core/config.ts b/src/core/config.ts index c7f3f082..b2bdc958 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -59,8 +59,12 @@ function readRuntimeConfig(args = process.argv.slice(2)): RuntimeConfigFile { return {}; } - const parsed = JSON.parse(readFileSync(path, "utf8")) as RuntimeConfigFile; - return parsed && typeof parsed === "object" ? parsed : {}; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as RuntimeConfigFile; + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return explicit ? { home: dirname(resolve(path)) } : {}; + } } function runtimeConfigArgsFromArgs(args: string[]): string[] {