diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 0000000..11e9d27 --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,42 @@ +name: Release Developer Preview + +on: + push: + tags: + - "v0.1.0-preview.*" + +permissions: + contents: write + +jobs: + release: + name: Build Apple Silicon DMG + runs-on: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: npm + cache-dependency-path: adapters/mcp/package-lock.json + + - name: Build preview package + env: + NEXUS_RUNTIME_CACHE: ${{ runner.temp }}/nexus-runtime-cache + run: | + scripts/build-preview.sh + scripts/verify-preview.sh + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + dist/preview/Nexus-*.dmg \ + dist/preview/SHA256SUMS \ + --title "Nexus $GITHUB_REF_NAME" \ + --prerelease \ + --generate-notes diff --git a/CHANGELOG.md b/CHANGELOG.md index 1daf9fe..02a56c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Nexus will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0-preview.1] - 2026-08-12 + +### Added +- Apple Silicon Developer Preview DMG with a real `Nexus.app` bundle +- Bundled Node.js 26.5.0 Runtime and MCP Helper with first-launch installation +- Stable MCP shim, runtime diagnostics, and reproducible package verification + ## [Unreleased] ### Added diff --git a/README.md b/README.md index 415b416..2ef1d65 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Nexus is a local-first trusted work-context layer for coding assistants on macOS It turns requirements, API examples, SQL, logs, source files, and human notes into a concise, reviewable, source-backed **Context Pack**, then carries later code changes forward as separate evidence. Once approved, that context is available to coding assistants such as Codex and Claude through MCP without injecting every raw material by default. -> Status: pre-alpha local prototype suitable for dogfooding. A production App Bundle, private Helper runtime, signing, notarization, and DMG are not complete yet. +> Status: pre-alpha Apple Silicon Developer Preview. The preview DMG includes a private Node Runtime and MCP Helper, but it is not Developer ID signed or notarized. ## Why Nexus @@ -60,7 +60,9 @@ Nexus does not run coding agents or decide implementation strategy. It never swi ## Requirements -Current development baseline: +For the Developer Preview, use an Apple Silicon Mac running macOS 14 or later. Node.js is bundled with the DMG and is not required for normal use. + +Source-development baseline: - macOS 14 or later; - Xcode 26.5 / Swift 6.3.2; @@ -71,6 +73,17 @@ The MCP Helper uses `node:sqlite`; it does not depend on the system SQLite CLI o ## Build and Run +Build the Apple Silicon Developer Preview DMG: + +```bash +scripts/build-preview.sh +scripts/verify-preview.sh +``` + +The DMG is written to `dist/preview/`. The build downloads the pinned official Node Runtime, verifies its SHA-256 checksum, and embeds it in `Nexus.app`; the runtime is not committed to this repository. + +The preview is not Developer ID signed or notarized. macOS may require opening it from Finder with the usual local preview confirmation. + Build and test Swift: ```bash @@ -117,7 +130,9 @@ An approved Context Pack can be expanded in the main workspace to inspect its fu ## MCP Helper -Install the development helper shim: +When Nexus is installed from the Developer Preview DMG, the first app launch automatically installs the bundled Helper into the user's Nexus Application Support directory and creates the stable command path below. No Node.js installation or manual Helper script is required. + +For source development only, install the development helper shim: ```bash scripts/install-helper.sh @@ -136,6 +151,8 @@ Check the Helper: "$HOME/Library/Application Support/Nexus/bin/nexus-mcp" --doctor ``` +`--doctor` reports the Helper version, MCP SDK version, private Node version, SQLite adapter, database, app runtime, and projection compatibility. + Example Codex configuration: ```bash @@ -181,6 +198,7 @@ Archiving or deleting a Work does not delete a Nexus-created directory or Git br - [Architecture](docs/architecture.md) - [Development guide](docs/development.md) +- [Developer Preview release](docs/release.md) - [Contributing](CONTRIBUTING.md) - [Security policy](SECURITY.md) - [Assistant rules example](docs/agent-rules.md) diff --git a/README.zh.md b/README.zh.md index b8cac42..1758d52 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,7 @@ Nexus 是 Mac 上面向编码助手的本地优先可信工作上下文层。 它把需求说明、接口样例、SQL、日志、代码文件和人的补充说明,整理成一份短小、可审核、带来源的 **Context Pack**,并把后续代码变化作为独立证据持续交接。确认后的上下文可通过 MCP 供 Codex、Claude 等编码助手按需读取,原始材料不会被默认全部塞进模型。 -> 当前状态:pre-alpha 本地原型,可用于 dogfooding。正式 App Bundle、私有 Helper runtime、签名、公证和 DMG 尚未完成。 +> 当前状态:pre-alpha Apple Silicon Developer Preview。预览版 DMG 已包含私有 Node Runtime 和 MCP Helper,但暂未进行 Developer ID 签名和公证。 ## 为什么使用 Nexus @@ -60,7 +60,9 @@ Nexus 不运行编码 Agent,不替用户决定实现方案,也不自动切 ## 环境要求 -当前开发基线: +Developer Preview 仅支持 Apple Silicon Mac,系统要求 macOS 14 或更高版本。DMG 已内置 Node.js,普通用户不需要自行安装 Node。 + +源码开发基线: - macOS 14 或更高版本; - Xcode 26.5 / Swift 6.3.2; @@ -71,6 +73,17 @@ MCP Helper 使用 `node:sqlite`,不依赖系统 SQLite CLI 或第三方 native ## 构建与运行 +构建 Apple Silicon Developer Preview DMG: + +```bash +scripts/build-preview.sh +scripts/verify-preview.sh +``` + +DMG 输出在 `dist/preview/`。构建过程会下载固定版本的官方 Node Runtime,校验 SHA-256 后嵌入 `Nexus.app`;Runtime 不会提交到仓库。 + +预览版暂未进行 Developer ID 签名和公证。macOS 可能需要在 Finder 中按系统提示确认打开本地预览应用。 + 构建并测试 Swift: ```bash @@ -117,7 +130,9 @@ Nexus 默认把数据保存到: ## MCP Helper -安装开发用 helper shim: +通过 Developer Preview DMG 安装后,首次启动 Nexus 会自动把内置 Helper 安装到本机 Nexus Application Support 目录,并创建下面的稳定命令路径。普通用户不需要安装 Node,也不需要手动执行 Helper 安装脚本。 + +源码开发时才需要安装开发用 helper shim: ```bash scripts/install-helper.sh @@ -136,6 +151,8 @@ scripts/install-helper.sh "$HOME/Library/Application Support/Nexus/bin/nexus-mcp" --doctor ``` +`--doctor` 会报告 Helper 版本、MCP SDK 版本、私有 Node 版本、SQLite 适配器、数据库、App 运行状态和投影兼容性。 + Codex 配置示例: ```bash @@ -181,6 +198,7 @@ codex mcp add nexus-worktree \ - [架构说明](docs/architecture.zh.md) - [开发指南](docs/development.zh.md) +- [Developer Preview 发布说明](docs/release.zh.md) - [参与贡献](CONTRIBUTING.md) - [安全策略](SECURITY.md) - [助手规则示例](docs/agent-rules.zh.md) diff --git a/Sources/NexusCore/BundledHelperInstaller.swift b/Sources/NexusCore/BundledHelperInstaller.swift new file mode 100644 index 0000000..ccb034e --- /dev/null +++ b/Sources/NexusCore/BundledHelperInstaller.swift @@ -0,0 +1,140 @@ +import Foundation + +public struct BundledHelperInstallation: Sendable { + public let helperVersion: String + public let nodeVersion: String + public let helperDirectory: URL + public let stableShim: URL + + public init(helperVersion: String, nodeVersion: String, helperDirectory: URL, stableShim: URL) { + self.helperVersion = helperVersion + self.nodeVersion = nodeVersion + self.helperDirectory = helperDirectory + self.stableShim = stableShim + } +} + +public enum BundledHelperInstallError: LocalizedError, Sendable { + case resourceMissing(URL) + case manifestInvalid(String) + case helperNotExecutable(URL) + case entrypointMissing(URL) + case installationFailed(String) + + public var errorDescription: String? { + switch self { + case .resourceMissing(let url): + return "Bundled MCP Helper resource is missing: \(url.path)" + case .manifestInvalid(let reason): + return "Bundled MCP Helper manifest is invalid: \(reason)" + case .helperNotExecutable(let url): + return "Bundled Node Runtime is not executable: \(url.path)" + case .entrypointMissing(let url): + return "Bundled MCP Helper entrypoint is missing: \(url.path)" + case .installationFailed(let reason): + return "Could not install the bundled MCP Helper: \(reason)" + } + } +} + +public enum BundledHelperInstaller { + private struct Manifest: Decodable { + let helperVersion: String + let nodeVersion: String + let entrypoint: String + } + + public static func install( + from resourceDirectory: URL, + applicationSupportDirectory: URL = NexusPaths.applicationSupportDirectory + ) throws -> BundledHelperInstallation { + let fileManager = FileManager.default + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: resourceDirectory.path, isDirectory: &isDirectory), isDirectory.boolValue + else { + throw BundledHelperInstallError.resourceMissing(resourceDirectory) + } + + let manifestURL = resourceDirectory.appendingPathComponent("manifest.json") + let manifest: Manifest + do { + manifest = try JSONDecoder().decode(Manifest.self, from: Data(contentsOf: manifestURL)) + } catch { + throw BundledHelperInstallError.manifestInvalid(error.localizedDescription) + } + + let resourceNode = resourceDirectory.appendingPathComponent("node") + guard fileManager.isExecutableFile(atPath: resourceNode.path) else { + throw BundledHelperInstallError.helperNotExecutable(resourceNode) + } + let resourceEntrypoint = resourceDirectory.appendingPathComponent(manifest.entrypoint) + guard fileManager.fileExists(atPath: resourceEntrypoint.path) else { + throw BundledHelperInstallError.entrypointMissing(resourceEntrypoint) + } + + let helpersDirectory = applicationSupportDirectory.appendingPathComponent("helpers", isDirectory: true) + let installedDirectory = helpersDirectory.appendingPathComponent(manifest.helperVersion, isDirectory: true) + let stableBinDirectory = applicationSupportDirectory.appendingPathComponent("bin", isDirectory: true) + let stableShim = stableBinDirectory.appendingPathComponent("nexus-mcp") + do { + try fileManager.createDirectory(at: helpersDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: stableBinDirectory, withIntermediateDirectories: true) + + if !isValidInstalledHelper(installedDirectory, entrypoint: manifest.entrypoint) { + let stagingDirectory = helpersDirectory.appendingPathComponent(".install-\(UUID().uuidString)") + try? fileManager.removeItem(at: stagingDirectory) + try fileManager.copyItem(at: resourceDirectory, to: stagingDirectory) + try? fileManager.removeItem(at: installedDirectory) + try fileManager.moveItem(at: stagingDirectory, to: installedDirectory) + } + + let installedNode = installedDirectory.appendingPathComponent("node") + let installedEntrypoint = installedDirectory.appendingPathComponent(manifest.entrypoint) + try writeStableShim( + at: stableShim, + node: installedNode, + entrypoint: installedEntrypoint, + applicationSupportDirectory: applicationSupportDirectory + ) + return BundledHelperInstallation( + helperVersion: manifest.helperVersion, + nodeVersion: manifest.nodeVersion, + helperDirectory: installedDirectory, + stableShim: stableShim + ) + } catch let error as BundledHelperInstallError { + throw error + } catch { + throw BundledHelperInstallError.installationFailed(error.localizedDescription) + } + } + + private static func isValidInstalledHelper(_ directory: URL, entrypoint: String) -> Bool { + let fileManager = FileManager.default + return fileManager.isExecutableFile(atPath: directory.appendingPathComponent("node").path) + && fileManager.fileExists(atPath: directory.appendingPathComponent(entrypoint).path) + } + + private static func writeStableShim( + at url: URL, + node: URL, + entrypoint: URL, + applicationSupportDirectory: URL + ) throws { + let script = """ + #!/bin/sh + set -eu + if [ -z "${NEXUS_HOME:-}" ]; then + NEXUS_HOME=\(shellQuote(applicationSupportDirectory.path)) + fi + export NEXUS_HOME + exec \(shellQuote(node.path)) \(shellQuote(entrypoint.path)) "$@" + """ + try Data(script.utf8).write(to: url, options: [.atomic]) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Sources/NexusMac/AppModel+AssistantConnection.swift b/Sources/NexusMac/AppModel+AssistantConnection.swift index 1612871..c518c8d 100644 --- a/Sources/NexusMac/AppModel+AssistantConnection.swift +++ b/Sources/NexusMac/AppModel+AssistantConnection.swift @@ -4,6 +4,22 @@ import NexusCore @MainActor extension AppModel { + func installBundledHelperIfAvailable() async { + guard let resourceDirectory = Bundle.main.resourceURL?.appendingPathComponent("MCPHelper") else { + return + } + do { + _ = try await NexusBackgroundWork.run(priority: .utility) { + try BundledHelperInstaller.install(from: resourceDirectory) + } + } catch { + assistantConnectionReady = false + assistantConnectionStatus = l10n.helperMissing + assistantConnectionDetail = l10n.installHelperFirst + assistantConnectionDoctor = error.localizedDescription + } + } + func copyContextSummary() { NSPasteboard.general.clearContents() NSPasteboard.general.setString(contextSummaryText(), forType: .string) diff --git a/Sources/NexusMac/AppModel.swift b/Sources/NexusMac/AppModel.swift index 0d7bd8e..a9afb85 100644 --- a/Sources/NexusMac/AppModel.swift +++ b/Sources/NexusMac/AppModel.swift @@ -384,6 +384,7 @@ final class AppModel: ObservableObject { guard let self else { return } defer { bootstrapTask = nil } do { + await installBundledHelperIfAvailable() try await performStoreOperation { store in try store.bootstrap() } diff --git a/Sources/NexusMac/L10n.swift b/Sources/NexusMac/L10n.swift index 35f660e..ae1b873 100644 --- a/Sources/NexusMac/L10n.swift +++ b/Sources/NexusMac/L10n.swift @@ -212,8 +212,9 @@ struct L10n { var textKind: String { zh ? "文本" : "Text" } var updatedPrefix: String { zh ? "更新" : "Updated" } var dropLocalFilesHere: String { zh ? "拖入本地文件" : "Drop local files here" } - var originalFilesStayOnDisk: String { zh ? "Nexus 只保留引用,不会删除原文件。" : "Original files stay on disk." } - var dropFiles: String { zh ? "拖入文件" : "Drop files" } + var filePickerHint: String { zh ? "点击选择或拖入,Nexus 只保留引用" : "Click to choose or drop; Nexus keeps a reference only" } + var dropFiles: String { zh ? "点击选择或拖入文件" : "Click to choose or drop files" } + var chooseFiles: String { zh ? "选择文件..." : "Choose Files..." } var snapshotSaved: String { zh ? "快照已保存" : "Snapshot saved" } var prepareCurrentWork: String { zh ? "整理当前工作" : "Prepare Current Work" } var contextPreparationTitle: String { zh ? "整理当前工作" : "Prepare Current Work" } @@ -487,9 +488,9 @@ struct L10n { var openNexusToConnect: String { zh ? "打开 Nexus 以连接" : "Open Nexus to connect" } var connectionFailed: String { zh ? "连接失败" : "Connection failed" } var helperInstalledButNotReady: String { zh ? "本地连接组件已安装,但还没有准备好。" : "The helper is installed but not ready." } - var runtimeStatusMissing: String { zh ? "Nexus 还没有发布运行状态。" : "Nexus has not published a running status yet." } + var runtimeStatusMissing: String { zh ? "连接状态还没有准备好,请稍后重试。" : "The connection is not ready yet. Please try again shortly." } var assistantAccessPausedReason: String { zh ? "助手读取已暂停。" : "Assistant reading is paused." } - var heartbeatStale: String { zh ? "Nexus 运行状态已过期。" : "Nexus heartbeat is stale." } + var heartbeatStale: String { zh ? "Nexus 暂时没有响应,请重新打开后重试。" : "Nexus is not responding. Reopen it and try again." } var appNotRunning: String { zh ? "Nexus 没有运行。" : "Nexus is not running." } var appProcessNotAlive: String { zh ? "Nexus 进程已退出。" : "Nexus process is no longer alive." } var chooseGitRepository: String { zh ? "选择 Git 主目录或已有代码目录" : "Choose a Git checkout or existing code directory" } diff --git a/Sources/NexusMac/NexusMacApp.swift b/Sources/NexusMac/NexusMacApp.swift index 4867f2a..2a8ca5f 100644 --- a/Sources/NexusMac/NexusMacApp.swift +++ b/Sources/NexusMac/NexusMacApp.swift @@ -16,7 +16,7 @@ struct NexusApp: App { .task { model.bootstrap() } } - WindowGroup("Nexus", id: "main") { + Window("Nexus", id: "main") { NexusMainView(model: model) .background(WindowActivator()) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -39,11 +39,13 @@ struct NexusApp: App { } } +@MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private var runtimeHeartbeatTimer: Timer? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) + installApplicationIcon() publishRuntimeHeartbeatFromDefaults() runtimeHeartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in publishRuntimeHeartbeatFromDefaults() @@ -55,4 +57,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { runtimeHeartbeatTimer = nil try? NexusRuntime.markAppStopped() } + + private func installApplicationIcon() { + guard let iconURL = Bundle.main.url(forResource: "Nexus", withExtension: "icns"), + let icon = NSImage(contentsOf: iconURL) else { + return + } + NSApp.applicationIconImage = icon + } } diff --git a/Sources/NexusMac/NexusMainView.swift b/Sources/NexusMac/NexusMainView.swift index 65aa3ac..aa54a13 100644 --- a/Sources/NexusMac/NexusMainView.swift +++ b/Sources/NexusMac/NexusMainView.swift @@ -751,6 +751,11 @@ struct NexusMainView: View { .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Menu { + Button { + chooseFiles() + } label: { + Label(l10n.chooseFiles, systemImage: "folder") + } Button { model.addClipboardTextMaterial() } label: { @@ -822,35 +827,65 @@ struct NexusMainView: View { } private var fileDropZone: some View { - RoundedRectangle(cornerRadius: 10) - .strokeBorder(style: StrokeStyle(lineWidth: model.contextMaterialCount == 0 ? 1.35 : 1, dash: [6, 5])) - .foregroundStyle( - model.isDropTargeted - ? Color.accentColor : Color.secondary.opacity(model.contextMaterialCount == 0 ? 0.42 : 0.24) - ) - .background(Color.secondary.opacity(model.contextMaterialCount == 0 ? 0.026 : 0.014)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay { - VStack(spacing: model.contextMaterialCount == 0 ? 6 : 2) { - Image(systemName: "doc.badge.plus") - .font(model.contextMaterialCount == 0 ? .body : .caption) - if model.contextMaterialCount == 0 { - Text(l10n.dropLocalFilesHere) - .font(.callout.weight(.medium)) - Text(l10n.originalFilesStayOnDisk) - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text(l10n.dropFiles) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) + Button { + chooseFiles() + } label: { + RoundedRectangle(cornerRadius: 10) + .strokeBorder( + style: StrokeStyle( + lineWidth: model.contextMaterialCount == 0 ? 1.35 : 1, + dash: [6, 5] + ) + ) + .foregroundStyle( + model.isDropTargeted + ? Color.accentColor + : Color.secondary.opacity(model.contextMaterialCount == 0 ? 0.42 : 0.24) + ) + .background(Color.secondary.opacity(model.contextMaterialCount == 0 ? 0.026 : 0.014)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + VStack(spacing: model.contextMaterialCount == 0 ? 6 : 2) { + Image(systemName: "doc.badge.plus") + .font(model.contextMaterialCount == 0 ? .body : .caption) + if model.contextMaterialCount == 0 { + Text(l10n.dropLocalFilesHere) + .font(.callout.weight(.medium)) + Text(l10n.filePickerHint) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(l10n.dropFiles) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } } } - } - .frame(height: model.contextMaterialCount == 0 ? 104 : 44) - .onDrop(of: [UTType.fileURL.identifier], isTargeted: $model.isDropTargeted) { providers in - model.handleDrop(providers: providers) - } + .frame(height: model.contextMaterialCount == 0 ? 104 : 44) + } + .buttonStyle(.plain) + .contentShape(RoundedRectangle(cornerRadius: 10)) + .onDrop(of: [UTType.fileURL.identifier], isTargeted: $model.isDropTargeted) { providers in + model.handleDrop(providers: providers) + } + .help(l10n.chooseFiles) + } + + private func chooseFiles() { + guard model.selectedTaskID != nil else { return } + let panel = NSOpenPanel() + panel.title = l10n.chooseFiles + panel.prompt = l10n.add + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = true + panel.resolvesAliases = true + panel.allowedContentTypes = [.item] + guard panel.runModal() == .OK else { return } + guard let taskID = model.selectedTaskID else { return } + for url in panel.urls { + model.addFile(url: url, taskID: taskID) + } } private var inspector: some View { diff --git a/Tests/NexusCoreTests/BundledHelperInstallerTests.swift b/Tests/NexusCoreTests/BundledHelperInstallerTests.swift new file mode 100644 index 0000000..0f32570 --- /dev/null +++ b/Tests/NexusCoreTests/BundledHelperInstallerTests.swift @@ -0,0 +1,51 @@ +import Foundation +import XCTest + +@testable import NexusCore + +final class BundledHelperInstallerTests: XCTestCase { + func testInstallsVersionedHelperAndStableShim() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("nexus-helper-\(UUID().uuidString)", isDirectory: true) + let resource = root.appendingPathComponent("resource", isDirectory: true) + let support = root.appendingPathComponent("support", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + + try FileManager.default.createDirectory( + at: resource.appendingPathComponent("dist", isDirectory: true), + withIntermediateDirectories: true + ) + try Data("#!/bin/sh\n".utf8).write(to: resource.appendingPathComponent("node")) + try Data("console.log('test')\n".utf8).write(to: resource.appendingPathComponent("dist/index.js")) + try Data( + """ + {"helperVersion":"0.1.0","nodeVersion":"v26.5.0","entrypoint":"dist/index.js"} + """.utf8 + ).write(to: resource.appendingPathComponent("manifest.json")) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: resource.appendingPathComponent("node").path + ) + + let first = try BundledHelperInstaller.install( + from: resource, + applicationSupportDirectory: support + ) + let second = try BundledHelperInstaller.install( + from: resource, + applicationSupportDirectory: support + ) + + XCTAssertEqual(first.helperVersion, "0.1.0") + XCTAssertEqual(first.nodeVersion, "v26.5.0") + XCTAssertEqual(first.helperDirectory, second.helperDirectory) + XCTAssertTrue(FileManager.default.isExecutableFile(atPath: first.stableShim.path)) + XCTAssertTrue( + FileManager.default.fileExists(atPath: first.helperDirectory.appendingPathComponent("dist/index.js").path)) + let shim = try String(contentsOf: first.stableShim, encoding: .utf8) + XCTAssertTrue(shim.contains("NEXUS_HOME")) + XCTAssertTrue(shim.contains("NEXUS_HOME='/")) + XCTAssertFalse(shim.contains("NEXUS_HOME=\"${NEXUS_HOME:-'/")) + XCTAssertTrue(shim.contains("node")) + } +} diff --git a/adapters/mcp/src/index.ts b/adapters/mcp/src/index.ts index b66cd31..c9cd3b2 100644 --- a/adapters/mcp/src/index.ts +++ b/adapters/mcp/src/index.ts @@ -2,7 +2,7 @@ import { ProjectionDatabase, type BindingSelection } from "./sqlite/ProjectionDatabase.js"; import { runMcpServer } from "./mcpServer.js"; -import { helperVersion } from "./version.js"; +import { helperVersion, mcpSdkVersion } from "./version.js"; const args = process.argv.slice(2); const selection = bindingSelection(args); @@ -15,7 +15,7 @@ if (args.includes("--version")) { if (args.includes("--doctor")) { const db = new ProjectionDatabase(undefined, selection); - console.log(JSON.stringify(db.doctor(), null, 2)); + console.log(JSON.stringify({ helperVersion, mcpSdkVersion, ...db.doctor() }, null, 2)); process.exit(0); } diff --git a/adapters/mcp/src/version.ts b/adapters/mcp/src/version.ts index 2fcadb4..1b4bd16 100644 --- a/adapters/mcp/src/version.ts +++ b/adapters/mcp/src/version.ts @@ -1 +1,9 @@ +import { readFileSync } from "node:fs"; + export const helperVersion = "0.1.0"; + +const packageMetadata = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8") +) as { dependencies?: Record }; + +export const mcpSdkVersion = packageMetadata.dependencies?.["@modelcontextprotocol/sdk"] ?? "unknown"; diff --git a/docs/development.md b/docs/development.md index 77dd04a..2ba0266 100644 --- a/docs/development.md +++ b/docs/development.md @@ -19,6 +19,8 @@ Workspace association is intentionally one-to-one by normalized workspace path. The MCP helper is pinned through `adapters/mcp/package-lock.json`. Do not use floating dependency versions for the helper. +The Apple Silicon Developer Preview is built with `scripts/build-preview.sh`. It embeds the pinned official Node Runtime and Helper resources in a real `Nexus.app`; `scripts/verify-preview.sh` checks the Bundle, Runtime, `node:sqlite`, DMG, and checksum. The preview is not signed or notarized. + ## Local Data By default, Nexus stores product data in: @@ -62,6 +64,13 @@ npm ci npm run build ``` +Build the preview package without changing the source checkout's installed dependencies: + +```bash +scripts/build-preview.sh +scripts/verify-preview.sh +``` + ## Verify Run Core and app tests: diff --git a/docs/development.zh.md b/docs/development.zh.md index 0742aca..2d63e20 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -19,6 +19,8 @@ MCP Helper 的依赖通过 `adapters/mcp/package-lock.json` 固定。Helper 不应使用浮动依赖版本。 +Apple Silicon Developer Preview 使用 `scripts/build-preview.sh` 构建。它会把固定版本的官方 Node Runtime 和 Helper 资源嵌入真实的 `Nexus.app`;`scripts/verify-preview.sh` 会检查 Bundle、Runtime、`node:sqlite`、DMG 和校验文件。预览版暂未签名或公证。 + ## 本地数据 默认情况下,Nexus 会把产品数据保存到: @@ -62,6 +64,13 @@ npm ci npm run build ``` +构建预览包,且不会修改源码目录中已安装的开发依赖: + +```bash +scripts/build-preview.sh +scripts/verify-preview.sh +``` + ## 验证 运行 Core 与 App 测试: diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..843100b --- /dev/null +++ b/docs/release.md @@ -0,0 +1,41 @@ +# Developer Preview Release + +## Scope + +The current preview targets Apple Silicon Macs running macOS 14 or later. The DMG contains the Nexus app, the MCP Helper, and the pinned private Node.js Runtime. Users do not need to install Node.js separately. + +The preview is not Developer ID signed or notarized. It is intended for local evaluation and dogfooding, not unattended enterprise deployment. + +## Build locally + +```bash +scripts/build-preview.sh +scripts/verify-preview.sh +``` + +The build downloads `node-v26.5.0-darwin-arm64.tar.gz` from the official Node.js distribution and verifies the pinned SHA-256 before embedding the Runtime. Generated App and DMG files remain under `dist/`, which is ignored by Git. + +## First launch + +Nexus copies the bundled Helper into: + +```text +~/Library/Application Support/Nexus/helpers// +``` + +It then creates the stable MCP command: + +```text +~/Library/Application Support/Nexus/bin/nexus-mcp +``` + +Existing MCP client configuration continues to use this stable path across Helper updates. + +## Diagnostics + +```bash +"$HOME/Library/Application Support/Nexus/bin/nexus-mcp" --version +"$HOME/Library/Application Support/Nexus/bin/nexus-mcp" --doctor +``` + +The diagnostic output includes runtime and projection compatibility information. It may include local paths; sanitize it before attaching to a public issue. diff --git a/docs/release.zh.md b/docs/release.zh.md new file mode 100644 index 0000000..49f1953 --- /dev/null +++ b/docs/release.zh.md @@ -0,0 +1,41 @@ +# Developer Preview 发布说明 + +## 范围 + +当前预览版面向 Apple Silicon Mac,系统要求 macOS 14 或更高版本。DMG 包含 Nexus App、MCP Helper 和固定版本的私有 Node.js Runtime,普通用户不需要额外安装 Node.js。 + +预览版暂未进行 Developer ID 签名和公证,适合本地体验与 dogfooding,不适合作为无人值守的企业部署包。 + +## 本地构建 + +```bash +scripts/build-preview.sh +scripts/verify-preview.sh +``` + +构建过程从官方 Node.js 分发地址下载 `node-v26.5.0-darwin-arm64.tar.gz`,校验固定的 SHA-256 后再嵌入 Runtime。生成的 App 和 DMG 位于 Git 忽略的 `dist/` 目录。 + +## 首次启动 + +Nexus 会把 App 内的 Helper 复制到: + +```text +~/Library/Application Support/Nexus/helpers// +``` + +然后创建稳定 MCP 命令: + +```text +~/Library/Application Support/Nexus/bin/nexus-mcp +``` + +Helper 更新时仍沿用这个稳定路径,已有 MCP 客户端配置不需要修改。 + +## 诊断 + +```bash +"$HOME/Library/Application Support/Nexus/bin/nexus-mcp" --version +"$HOME/Library/Application Support/Nexus/bin/nexus-mcp" --doctor +``` + +诊断结果包含 Runtime 和投影兼容性信息,可能包含本机路径。提交公开 Issue 前请先脱敏。 diff --git a/packaging/AppIcon-master.png b/packaging/AppIcon-master.png new file mode 100644 index 0000000..25db490 Binary files /dev/null and b/packaging/AppIcon-master.png differ diff --git a/packaging/Info.plist b/packaging/Info.plist new file mode 100644 index 0000000..a6913f0 --- /dev/null +++ b/packaging/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Nexus + CFBundleExecutable + NexusMac + CFBundleIdentifier + com.norasoong.nexus + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Nexus + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0-preview.1 + CFBundleIconFile + Nexus.icns + LSMinimumSystemVersion + 14.0 + LSMultipleInstancesProhibited + + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright (c) 2026 NoraSoong + LSApplicationCategoryType + public.app-category.developer-tools + + diff --git a/packaging/helper-manifest.json.in b/packaging/helper-manifest.json.in new file mode 100644 index 0000000..75fff05 --- /dev/null +++ b/packaging/helper-manifest.json.in @@ -0,0 +1,8 @@ +{ + "helperVersion": "0.1.0", + "nodeVersion": "v26.5.0", + "mcpSdkVersion": "1.30.0", + "projectionSchemaVersion": 2, + "runtimeMode": "bundled-node", + "entrypoint": "dist/index.js" +} diff --git a/packaging/node-runtime.sha256 b/packaging/node-runtime.sha256 new file mode 100644 index 0000000..9f765e3 --- /dev/null +++ b/packaging/node-runtime.sha256 @@ -0,0 +1 @@ +ee920559aaa2391569cff4d737e3b83963430e3a14dedd91bfe0ff53171b5af9 node-v26.5.0-darwin-arm64.tar.gz diff --git a/scripts/build-preview.sh b/scripts/build-preview.sh new file mode 100755 index 0000000..58429f5 --- /dev/null +++ b/scripts/build-preview.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUTPUT_DIR="${NEXUS_PREVIEW_OUTPUT:-$ROOT/dist/preview}" +CACHE_DIR="${NEXUS_RUNTIME_CACHE:-${HOME}/Library/Caches/Nexus}" +APP_NAME="Nexus.app" +APP_VERSION="0.1.0-preview.1" +NODE_VERSION="v26.5.0" +NODE_ARCHIVE="node-${NODE_VERSION}-darwin-arm64.tar.gz" +NODE_URL="https://nodejs.org/dist/${NODE_VERSION}/${NODE_ARCHIVE}" +NODE_CHECKSUM="$(awk '{print $1}' "$ROOT/packaging/node-runtime.sha256")" + +if [[ "$(uname -m)" != "arm64" ]]; then + echo "This preview build supports Apple Silicon (arm64) only." >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" "$CACHE_DIR" +rm -rf "$OUTPUT_DIR/$APP_NAME" "$OUTPUT_DIR/dmg-staging" +rm -f "$OUTPUT_DIR"/Nexus-*.dmg "$OUTPUT_DIR"/SHA256SUMS + +RUNTIME_ARCHIVE="$CACHE_DIR/$NODE_ARCHIVE" +if [[ ! -f "$RUNTIME_ARCHIVE" ]]; then + echo "Downloading official Node ${NODE_VERSION} arm64 runtime..." + curl --fail --location --silent --show-error "$NODE_URL" --output "$RUNTIME_ARCHIVE" +fi + +EXPECTED="${NODE_CHECKSUM} ${RUNTIME_ARCHIVE}" +printf '%s\n' "$EXPECTED" | shasum -a 256 -c - + +SWIFT_SCRATCH="$ROOT/.build/preview-swiftpm" +swift build -c release --product NexusMac --scratch-path "$SWIFT_SCRATCH" +SWIFT_BIN_DIR="$(swift build -c release --product NexusMac --scratch-path "$SWIFT_SCRATCH" --show-bin-path)" + +HELPER_BUILD_DIR="$ROOT/.build/preview-helper" +rm -rf "$HELPER_BUILD_DIR" +mkdir -p "$HELPER_BUILD_DIR" +cp "$ROOT/adapters/mcp/package.json" "$ROOT/adapters/mcp/package-lock.json" "$ROOT/adapters/mcp/tsconfig.json" "$HELPER_BUILD_DIR/" +cp -R "$ROOT/adapters/mcp/src" "$HELPER_BUILD_DIR/src" +pushd "$HELPER_BUILD_DIR" >/dev/null +npm ci +npm run build +npm prune --omit=dev +popd >/dev/null + +APP_DIR="$OUTPUT_DIR/$APP_NAME" +MACOS_DIR="$APP_DIR/Contents/MacOS" +RESOURCES_DIR="$APP_DIR/Contents/Resources" +HELPER_DIR="$RESOURCES_DIR/MCPHelper" +ICONSET_DIR="$ROOT/.build/preview-appicon.iconset" +ICON_PATH="$ROOT/.build/preview-Nexus.icns" +rm -rf "$ICONSET_DIR" +mkdir -p "$ICONSET_DIR" +for size in 16 32 128 256 512; do + double_size=$((size * 2)) + sips -z "$size" "$size" "$ROOT/packaging/AppIcon-master.png" \ + --out "$ICONSET_DIR/icon_${size}x${size}.png" >/dev/null + sips -z "$double_size" "$double_size" "$ROOT/packaging/AppIcon-master.png" \ + --out "$ICONSET_DIR/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$ICONSET_DIR" -o "$ICON_PATH" +mkdir -p "$MACOS_DIR" "$HELPER_DIR/dist" "$HELPER_DIR/node_modules" + +cp "$SWIFT_BIN_DIR/NexusMac" "$MACOS_DIR/NexusMac" +cp "$ROOT/packaging/Info.plist" "$APP_DIR/Contents/Info.plist" +cp "$ICON_PATH" "$RESOURCES_DIR/Nexus.icns" +cp -R "$HELPER_BUILD_DIR/dist/." "$HELPER_DIR/dist/" +cp -R "$HELPER_BUILD_DIR/node_modules/." "$HELPER_DIR/node_modules/" +cp "$HELPER_BUILD_DIR/package.json" "$HELPER_DIR/package.json" +cp "$HELPER_BUILD_DIR/package-lock.json" "$HELPER_DIR/package-lock.json" +node "$ROOT/scripts/generate-helper-notices.mjs" \ + "$HELPER_BUILD_DIR/package-lock.json" \ + "$HELPER_DIR/THIRD_PARTY_NOTICES.txt" +cp "$ROOT/packaging/helper-manifest.json.in" "$HELPER_DIR/manifest.json" +chmod +x "$MACOS_DIR/NexusMac" + +RUNTIME_TMP="$(mktemp -d "${TMPDIR:-/tmp}/nexus-node.XXXXXX")" +trap 'rm -rf "$RUNTIME_TMP"' EXIT +tar -xzf "$RUNTIME_ARCHIVE" -C "$RUNTIME_TMP" +cp "$RUNTIME_TMP/node-${NODE_VERSION}-darwin-arm64/bin/node" "$HELPER_DIR/node" +cp "$RUNTIME_TMP/node-${NODE_VERSION}-darwin-arm64/LICENSE" "$HELPER_DIR/node-LICENSE" +chmod +x "$HELPER_DIR/node" + +"$HELPER_DIR/node" --version +"$HELPER_DIR/node" -e "import('node:sqlite').then(() => console.log('node:sqlite OK'))" + +STAGING_DIR="$OUTPUT_DIR/dmg-staging" +mkdir -p "$STAGING_DIR" +cp -R "$APP_DIR" "$STAGING_DIR/$APP_NAME" +ln -s /Applications "$STAGING_DIR/Applications" + +DMG_PATH="$OUTPUT_DIR/Nexus-${APP_VERSION}-arm64.dmg" +hdiutil create -volname "Nexus ${APP_VERSION}" -srcfolder "$STAGING_DIR" -ov -format UDZO "$DMG_PATH" >/dev/null +rm -rf "$STAGING_DIR" + +shasum -a 256 "$DMG_PATH" | sed "s#${OUTPUT_DIR}/##" > "$OUTPUT_DIR/SHA256SUMS" +echo "$DMG_PATH" diff --git a/scripts/generate-helper-notices.mjs b/scripts/generate-helper-notices.mjs new file mode 100644 index 0000000..7f37bd6 --- /dev/null +++ b/scripts/generate-helper-notices.mjs @@ -0,0 +1,25 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +const [, , lockPath, outputPath] = process.argv; +if (!lockPath || !outputPath) { + throw new Error("usage: generate-helper-notices.mjs package-lock.json output.txt"); +} + +const lock = JSON.parse(readFileSync(lockPath, "utf8")); +const entries = Object.entries(lock.packages ?? {}) + .filter(([path, metadata]) => path.startsWith("node_modules/") && metadata?.version) + .map(([path, metadata]) => ({ + name: path.slice("node_modules/".length), + version: metadata.version, + license: metadata.license ?? "unspecified" + })) + .sort((a, b) => a.name.localeCompare(b.name)); + +const lines = [ + "Nexus MCP Helper third-party packages", + "", + ...entries.map((entry) => `${entry.name}@${entry.version} — ${entry.license}`), + "", + "Refer to each package's published license for the full license text." +]; +writeFileSync(outputPath, `${lines.join("\n")}\n`, "utf8"); diff --git a/scripts/install-helper.sh b/scripts/install-helper.sh index 2300e8f..22f2cd9 100755 --- a/scripts/install-helper.sh +++ b/scripts/install-helper.sh @@ -29,6 +29,10 @@ HELPER_DIR="$NEXUS_HOME/helpers/$HELPER_VERSION" BIN_DIR="$NEXUS_HOME/bin" mkdir -p "$HELPER_DIR" "$BIN_DIR" +printf -v QUOTED_NEXUS_HOME '%q' "$NEXUS_HOME" +printf -v QUOTED_NODE_BIN '%q' "$NODE_BIN" +printf -v QUOTED_HELPER_ENTRYPOINT '%q' "$HELPER_DIR/dist/index.js" + rm -rf "$HELPER_DIR/dist" cp -R "$ROOT/adapters/mcp/dist" "$HELPER_DIR/dist" rm -rf "$HELPER_DIR/node_modules" @@ -47,8 +51,11 @@ JSON cat > "$BIN_DIR/nexus-mcp" < process.stdout.write('ok'))")" == "ok" ]] +"$HELPER_DIR/node" "$HELPER_DIR/dist/index.js" --version + +DMG_PATH="$(find "$OUTPUT_DIR" -maxdepth 1 -name 'Nexus-*.dmg' -print -quit)" +[[ -n "$DMG_PATH" ]] +hdiutil imageinfo "$DMG_PATH" >/dev/null +(cd "$OUTPUT_DIR" && shasum -a 256 -c SHA256SUMS) +echo "Preview package verification passed."