diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd4c2dba4..60b7001cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -299,6 +299,21 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Prepare OfficeCLI + if: ${{ inputs.phase != 'finalize' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + macos) officecli_platform="darwin" ;; + windows) officecli_platform="win32" ;; + *) echo "Unsupported OfficeCLI target: ${{ matrix.target }}"; exit 1 ;; + esac + + bun ./scripts/prepare-officecli.ts --platform "$officecli_platform" --arch "${{ matrix.arch_label }}" + working-directory: packages/desktop-electron + - name: Read desktop package version id: package_version shell: bash diff --git a/.github/workflows/desktop-smoke.yml b/.github/workflows/desktop-smoke.yml index 489226427..2a8469d2c 100644 --- a/.github/workflows/desktop-smoke.yml +++ b/.github/workflows/desktop-smoke.yml @@ -128,6 +128,10 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Prepare OfficeCLI + run: bun ./scripts/prepare-officecli.ts --platform darwin --arch arm64 + working-directory: packages/desktop-electron + - name: Build desktop app run: bun run build working-directory: packages/desktop-electron @@ -164,6 +168,8 @@ jobs: EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/PawWork Dev" INFO_PLIST_PATH="$APP_PATH/Contents/Info.plist" ASAR_PATH="$APP_PATH/Contents/Resources/app.asar" + OFFICECLI_PATH="$APP_PATH/Contents/Resources/tools/officecli" + THIRD_PARTY_NOTICES_PATH="$APP_PATH/Contents/Resources/THIRD_PARTY_NOTICES.md" FRAMEWORK_PATH="$APP_PATH/Contents/Frameworks/Electron Framework.framework" HELPER_APP_PATH="$APP_PATH/Contents/Frameworks/PawWork Dev Helper.app" @@ -187,6 +193,19 @@ jobs: exit 1 fi + if [ ! -x "$OFFICECLI_PATH" ]; then + echo "Expected executable OfficeCLI at $OFFICECLI_PATH" + exit 1 + fi + + file "$OFFICECLI_PATH" | grep -q "arm64" + OFFICECLI_SKIP_UPDATE=1 "$OFFICECLI_PATH" --version + + if [ ! -f "$THIRD_PARTY_NOTICES_PATH" ]; then + echo "Expected third-party notices at $THIRD_PARTY_NOTICES_PATH" + exit 1 + fi + if [ ! -d "$FRAMEWORK_PATH" ]; then echo "Expected Electron Framework at $FRAMEWORK_PATH" exit 1 diff --git a/README.md b/README.md index b8fb124b6..685d47aef 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ PawWork is built on a fork of [OpenCode](https://github.com/anomalyco/opencode). Thanks to the OpenCode project and community. +PawWork bundles [OfficeCLI](https://github.com/iOfficeAI/OfficeCLI) by iOfficeAI to handle Word, Excel, and PowerPoint files locally. Thanks to iOfficeAI for the Apache-2.0 open-source OfficeCLI project. + ## License [Apache License 2.0](LICENSE) diff --git a/README_CN.md b/README_CN.md index 0c20816cb..3d409f34c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -86,6 +86,8 @@ bun run dev:desktop 感谢 OpenCode 项目和社区。 +爪印 PawWork 内置 iOfficeAI 的 [OfficeCLI](https://github.com/iOfficeAI/OfficeCLI),用于在本地处理 Word、Excel 和 PowerPoint 文件。感谢 iOfficeAI 以 Apache-2.0 开源 OfficeCLI。 + ## License [Apache License 2.0](LICENSE) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..38ae9601f --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,215 @@ +# Third-Party Notices + +## OfficeCLI + +PawWork bundles OfficeCLI by iOfficeAI to read and edit Word, Excel, and PowerPoint files locally. + +- Project: https://github.com/iOfficeAI/OfficeCLI +- License: Apache License 2.0 + +The Apache License 2.0 text for OfficeCLI follows. + +## Apache License 2.0 + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this definition, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. Please also get an + OpenSourceInitiative.org approved license identifier and put it + in the first line of your license text file. + + SPDX-License-Identifier: Apache-2.0 + + Copyright 2026 OfficeCli (https://OfficeCli.AI) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing + permissions and limitations under the License. diff --git a/packages/desktop-electron/bundled-tools.json b/packages/desktop-electron/bundled-tools.json new file mode 100644 index 000000000..add272b0d --- /dev/null +++ b/packages/desktop-electron/bundled-tools.json @@ -0,0 +1,12 @@ +{ + "officecli": { + "version": "v1.0.63", + "repo": "iOfficeAI/OfficeCLI", + "assets": { + "darwin-arm64": "officecli-mac-arm64", + "darwin-x64": "officecli-mac-x64", + "win32-x64": "officecli-win-x64.exe", + "win32-arm64": "officecli-win-arm64.exe" + } + } +} diff --git a/packages/desktop-electron/electron-builder-app-update.test.ts b/packages/desktop-electron/electron-builder-app-update.test.ts index ebd053fa5..319cf5d5e 100644 --- a/packages/desktop-electron/electron-builder-app-update.test.ts +++ b/packages/desktop-electron/electron-builder-app-update.test.ts @@ -62,6 +62,18 @@ describe("electron builder app-update config", () => { expect(createConfig("prod").artifactName).toBe("pawwork-${os}-${arch}-${version}.${ext}") }) + test("packages third-party notices into app resources", () => { + const config = createConfig("prod") + expect(config.extraResources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: expect.stringContaining("THIRD_PARTY_NOTICES.md"), + to: "THIRD_PARTY_NOTICES.md", + }), + ]), + ) + }) + test("afterPack writes app-update.yml to the packager-reported macOS resources path", async () => { const root = mkdtempSync(join(tmpdir(), "pawwork-builder-config-")) roots.push(root) diff --git a/packages/desktop-electron/electron-builder.config.ts b/packages/desktop-electron/electron-builder.config.ts index be2dd3328..1b158413a 100644 --- a/packages/desktop-electron/electron-builder.config.ts +++ b/packages/desktop-electron/electron-builder.config.ts @@ -64,6 +64,10 @@ const getBase = (): Configuration => ({ to: "skills", filter: ["**/*"], }, + { + from: path.join(rootDir, "THIRD_PARTY_NOTICES.md"), + to: "THIRD_PARTY_NOTICES.md", + }, { from: "native/", to: "native/", diff --git a/packages/desktop-electron/resources/tools/officecli b/packages/desktop-electron/resources/tools/officecli deleted file mode 100755 index 4037a0b09..000000000 Binary files a/packages/desktop-electron/resources/tools/officecli and /dev/null differ diff --git a/packages/desktop-electron/scripts/download-tools.ts b/packages/desktop-electron/scripts/download-tools.ts index eae0e1d70..d25725cb8 100644 --- a/packages/desktop-electron/scripts/download-tools.ts +++ b/packages/desktop-electron/scripts/download-tools.ts @@ -7,6 +7,8 @@ import { chmodSync, createWriteStream, existsSync, mkdirSync, renameSync, unlink import { pipeline } from "node:stream/promises" import path from "node:path" +import { officeCliTargetFor, prepareOfficeCli } from "./prepare-officecli" + const TOOLS_DIR = path.resolve(import.meta.dirname, "../resources/tools") const platform = process.argv.includes("--platform") @@ -31,22 +33,6 @@ interface Tool { } const tools: Tool[] = [ - { - name: "officecli", - getUrl: (p, a) => { - const map: Record = { - "darwin-arm64": "officecli-mac-arm64", - "darwin-x64": "officecli-mac-x64", - "win32-x64": "officecli-win-x64.exe", - "win32-arm64": "officecli-win-arm64.exe", - } - const file = map[`${p}-${a}`] - if (!file) return null - return `https://github.com/iOfficeAI/OfficeCLI/releases/latest/download/${file}` - }, - getBinaryName: (p) => (p === "win32" ? "officecli.exe" : "officecli"), - extract: "none", - }, { name: "lark-cli", getUrl: (_p, _a) => { @@ -200,6 +186,12 @@ async function downloadWecomCli() { // --- Main --- async function main() { + const officeCliTarget = officeCliTargetFor(platform, arch) + if (officeCliTarget) { + await prepareOfficeCli(officeCliTarget.platform, officeCliTarget.arch) + } else { + console.log(` Skipping officecli (no URL for ${platform}-${arch})`) + } for (const tool of tools) { if (tool.name === "lark-cli") continue // handled separately await downloadTool(tool) diff --git a/packages/desktop-electron/scripts/prepare-officecli.test.ts b/packages/desktop-electron/scripts/prepare-officecli.test.ts new file mode 100644 index 000000000..de91ca561 --- /dev/null +++ b/packages/desktop-electron/scripts/prepare-officecli.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" + +import { + assetForTarget, + binaryNameForPlatform, + officeCliDownloadUrl, + officeCliTargetFor, + officeCliVersionMatches, + officeCliSha256SumsUrl, + parseSha256Sums, + runtimeBinaryPath, +} from "./prepare-officecli" + +describe("prepare-officecli manifest helpers", () => { + test("maps supported targets to upstream OfficeCLI assets", () => { + expect(assetForTarget("darwin", "arm64")).toBe("officecli-mac-arm64") + expect(assetForTarget("darwin", "x64")).toBe("officecli-mac-x64") + expect(assetForTarget("win32", "x64")).toBe("officecli-win-x64.exe") + expect(assetForTarget("win32", "arm64")).toBe("officecli-win-arm64.exe") + }) + + test("rejects unsupported targets", () => { + expect(() => assetForTarget("linux" as any, "x64")).toThrow("Unsupported OfficeCLI target: linux-x64") + }) + + test("returns supported targets with narrowed platform and arch", () => { + expect(officeCliTargetFor("darwin", "arm64")).toEqual({ platform: "darwin", arch: "arm64" }) + expect(officeCliTargetFor("win32", "x64")).toEqual({ platform: "win32", arch: "x64" }) + expect(officeCliTargetFor("linux", "x64")).toBeNull() + }) + + test("uses platform runtime binary names", () => { + expect(binaryNameForPlatform("darwin")).toBe("officecli") + expect(binaryNameForPlatform("win32")).toBe("officecli.exe") + }) + + test("builds pinned release URLs and does not use latest", () => { + const url = officeCliDownloadUrl("v1.0.63", "officecli-win-x64.exe") + expect(url).toBe("https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.63/officecli-win-x64.exe") + expect(url).not.toContain("/latest/") + expect(officeCliSha256SumsUrl("v1.0.63")).toBe( + "https://github.com/iOfficeAI/OfficeCLI/releases/download/v1.0.63/SHA256SUMS", + ) + }) + + test("parses SHA256SUMS entries by asset name", () => { + expect( + parseSha256Sums( + "3ede6c3457f050f2d06d95895d7a3391183911ad729c61df990d4e27c1067510 officecli-mac-arm64\nB687396B3A44C6A6AAB7A1A3D9D2325E38D9E7D7F8BF632C2EB2D2B8A9C4872C *officecli-win-x64.exe\n", + ).get("officecli-win-x64.exe"), + ).toBe("b687396b3a44c6a6aab7a1a3d9d2325e38d9e7d7f8bf632c2eb2d2b8a9c4872c") + }) + + test("ignores malformed SHA256SUMS lines", () => { + expect(parseSha256Sums("not-a-sum officecli\n").size).toBe(0) + }) + + test("matches the exact OfficeCLI version token", () => { + expect(officeCliVersionMatches("officecli 1.0.63\n", "v1.0.63")).toBe(true) + expect(officeCliVersionMatches("officecli 1.0.630\n", "v1.0.63")).toBe(false) + }) + + test("resolves runtime binary paths under the tools directory", () => { + expect(runtimeBinaryPath("/repo/packages/desktop-electron/resources/tools", "win32")).toBe( + path.join("/repo/packages/desktop-electron/resources/tools", "officecli.exe"), + ) + }) +}) diff --git a/packages/desktop-electron/scripts/prepare-officecli.ts b/packages/desktop-electron/scripts/prepare-officecli.ts new file mode 100644 index 000000000..dd6b0dfb0 --- /dev/null +++ b/packages/desktop-electron/scripts/prepare-officecli.ts @@ -0,0 +1,129 @@ +import { execFile } from "node:child_process" +import { createHash } from "node:crypto" +import { chmod, mkdir, rm, writeFile } from "node:fs/promises" +import path from "node:path" +import { promisify } from "node:util" + +import manifest from "../bundled-tools.json" + +export type SupportedPlatform = "darwin" | "win32" +export type SupportedArch = "arm64" | "x64" +export interface OfficeCliTarget { + platform: SupportedPlatform + arch: SupportedArch +} + +const execFileAsync = promisify(execFile) +const toolsDir = path.resolve(import.meta.dirname, "../resources/tools") +const officeCli = manifest.officecli + +export function officeCliTargetFor(platform: string, arch: string): OfficeCliTarget | null { + if (!(`${platform}-${arch}` in officeCli.assets)) return null + return { platform: platform as SupportedPlatform, arch: arch as SupportedArch } +} + +export function assetForTarget(platform: SupportedPlatform, arch: SupportedArch) { + const asset = officeCli.assets[`${platform}-${arch}` as keyof typeof officeCli.assets] + if (!asset) throw new Error(`Unsupported OfficeCLI target: ${platform}-${arch}`) + return asset +} + +export function binaryNameForPlatform(platform: SupportedPlatform) { + return platform === "win32" ? "officecli.exe" : "officecli" +} + +export function runtimeBinaryPath(baseToolsDir: string, platform: SupportedPlatform) { + return path.join(baseToolsDir, binaryNameForPlatform(platform)) +} + +export function officeCliDownloadUrl(version: string, asset: string) { + return `https://github.com/${officeCli.repo}/releases/download/${version}/${asset}` +} + +export function officeCliSha256SumsUrl(version: string) { + return `https://github.com/${officeCli.repo}/releases/download/${version}/SHA256SUMS` +} + +export function parseSha256Sums(text: string) { + const entries = new Map() + for (const line of text.split(/\r?\n/)) { + const match = line.trim().match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/) + if (!match) continue + entries.set(match[2].trim(), match[1].toLowerCase()) + } + return entries +} + +export function sha256(data: ArrayBuffer) { + return createHash("sha256").update(Buffer.from(data)).digest("hex") +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +export function officeCliVersionMatches(stdout: string, expectedVersion: string) { + const normalized = expectedVersion.replace(/^v/, "") + return new RegExp(`\\b${escapeRegExp(normalized)}\\b`).test(stdout) +} + +async function fetchBytes(url: string) { + const response = await fetch(url, { redirect: "follow" }) + if (!response.ok) throw new Error(`Failed to download ${url}: HTTP ${response.status}`) + return response.arrayBuffer() +} + +async function fetchText(url: string) { + const response = await fetch(url, { redirect: "follow" }) + if (!response.ok) throw new Error(`Failed to download ${url}: HTTP ${response.status}`) + return response.text() +} + +export async function verifyOfficeCliVersion(binaryPath: string, expectedVersion: string) { + const { stdout } = await execFileAsync(binaryPath, ["--version"], { + env: { ...process.env, OFFICECLI_SKIP_UPDATE: "1" }, + }) + if (!officeCliVersionMatches(stdout, expectedVersion)) { + throw new Error(`OfficeCLI version mismatch: expected ${expectedVersion}, got ${stdout.trim()}`) + } +} + +export async function prepareOfficeCli(targetPlatform: SupportedPlatform, targetArch: SupportedArch) { + const asset = assetForTarget(targetPlatform, targetArch) + const runtimeName = binaryNameForPlatform(targetPlatform) + const assetUrl = officeCliDownloadUrl(officeCli.version, asset) + const sums = parseSha256Sums(await fetchText(officeCliSha256SumsUrl(officeCli.version))) + const expected = sums.get(asset) + if (!expected) throw new Error(`SHA256SUMS does not include ${asset}`) + + const data = await fetchBytes(assetUrl) + const actual = sha256(data) + if (actual !== expected) { + throw new Error(`Checksum mismatch for ${asset}: expected ${expected}, got ${actual}`) + } + + await mkdir(toolsDir, { recursive: true }) + await rm(path.join(toolsDir, "officecli"), { force: true }) + await rm(path.join(toolsDir, "officecli.exe"), { force: true }) + const destination = path.join(toolsDir, runtimeName) + await writeFile(destination, Buffer.from(data)) + if (targetPlatform !== "win32") await chmod(destination, 0o755) + + if (targetPlatform === process.platform && targetArch === process.arch) { + await verifyOfficeCliVersion(destination, officeCli.version) + } + + return { asset, destination, version: officeCli.version } +} + +function readArg(name: string) { + const index = process.argv.indexOf(name) + return index === -1 ? undefined : process.argv[index + 1] +} + +if (import.meta.main) { + const platform = (readArg("--platform") ?? process.platform) as SupportedPlatform + const arch = (readArg("--arch") ?? process.arch) as SupportedArch + const result = await prepareOfficeCli(platform, arch) + console.log(`Prepared OfficeCLI ${result.version} for ${platform}-${arch}: ${result.destination}`) +} diff --git a/packages/desktop-electron/scripts/release-workflow-contract.test.ts b/packages/desktop-electron/scripts/release-workflow-contract.test.ts index b389122e5..e7703206f 100644 --- a/packages/desktop-electron/scripts/release-workflow-contract.test.ts +++ b/packages/desktop-electron/scripts/release-workflow-contract.test.ts @@ -4,6 +4,14 @@ import { join } from "node:path" const workflow = readFileSync(join(import.meta.dir, "..", "..", "..", ".github", "workflows", "build.yml"), "utf8") +function expectBefore(haystack: string, before: string, after: string) { + const beforeIndex = haystack.indexOf(before) + const afterIndex = haystack.indexOf(after) + expect(beforeIndex).toBeGreaterThanOrEqual(0) + expect(afterIndex).toBeGreaterThanOrEqual(0) + expect(beforeIndex).toBeLessThan(afterIndex) +} + describe("release workflow app-update verification", () => { test("does not mutate app-update.yml after signing", () => { expect(workflow).not.toContain("write-app-update-config") @@ -32,4 +40,15 @@ describe("release workflow app-update verification", () => { test("keeps finalize phase packaging from the prepackaged signed app", () => { expect(workflow).toContain('npx electron-builder --mac dmg zip --${{ matrix.arch_label }} --prepackaged "$APP_PATH"') }) + + test("prepares OfficeCLI before signed macOS packaging", () => { + expectBefore(workflow, "Prepare OfficeCLI", "npx electron-builder --mac dir") + expect(workflow).toContain("bun ./scripts/prepare-officecli.ts") + expect(workflow).toContain('officecli_platform="darwin"') + }) + + test("prepares OfficeCLI before Windows packaging", () => { + expectBefore(workflow, "Prepare OfficeCLI", "npx electron-builder ${{ matrix.platform_flag }}") + expect(workflow).toContain('officecli_platform="win32"') + }) }) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index ecb859d94..cc176b2da 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -407,6 +407,7 @@ export const BashTool = Tool.define( return withoutInternalServerAuthEnv({ ...process.env, ...extraEnv, + OFFICECLI_SKIP_UPDATE: "1", PATH: bundledToolsDir ? `${bundledToolsDir}${path.delimiter}${currentPath}` : currentPath, }) }) diff --git a/packages/opencode/test/github/desktop-smoke-workflow.test.ts b/packages/opencode/test/github/desktop-smoke-workflow.test.ts index 6498316b4..fbcb0da40 100644 --- a/packages/opencode/test/github/desktop-smoke-workflow.test.ts +++ b/packages/opencode/test/github/desktop-smoke-workflow.test.ts @@ -24,6 +24,7 @@ describe("desktop smoke workflow", () => { const runtimeGuardStep = smokeSteps.find((step) => step.name === "Check desktop runtime imports") const packagedSmokeStep = smokeSteps.find((step) => step.name === "Launch packaged desktop smoke app") const buildStep = smokeSteps.find((step) => step.name === "Build desktop app") + const prepareOfficeCliStep = smokeSteps.find((step) => step.name === "Prepare OfficeCLI") expect(parsed.name).toBe("desktop-smoke") expect(parsed.on?.push).toEqual({ branches: ["dev"] }) @@ -51,6 +52,9 @@ describe("desktop smoke workflow", () => { expect(smokeCheckoutStep?.with).toEqual({ "persist-credentials": false }) expect(smokeBunStep?.uses).toBe("oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6") expect(workflow).toContain("bun install --frozen-lockfile") + expect(prepareOfficeCliStep).toBeDefined() + expect(prepareOfficeCliStep?.run).toBe("bun ./scripts/prepare-officecli.ts --platform darwin --arch arm64") + expect(prepareOfficeCliStep?.["working-directory"]).toBe("packages/desktop-electron") expect(workflow).toContain("bun run build") expect(appSmokeStep?.run).toBe("bun run smoke:ci") expect(workflow).toContain("Launch desktop smoke app") @@ -73,6 +77,8 @@ describe("desktop smoke workflow", () => { expect(packagedSmokeStep?.run).toContain('bun ./scripts/ci-smoke.ts packaged dev "$EXECUTABLE_PATH"') expect(packagedSmokeStep?.["working-directory"]).toBe("packages/desktop-electron") expect(buildStep).toBeDefined() + expect(smokeSteps.indexOf(prepareOfficeCliStep!)).toBeGreaterThan(smokeSteps.indexOf(smokeBunStep!)) + expect(smokeSteps.indexOf(prepareOfficeCliStep!)).toBeLessThan(smokeSteps.indexOf(buildStep!)) expect(smokeSteps.indexOf(runtimeGuardStep!)).toBeGreaterThan(smokeSteps.indexOf(buildStep!)) expect(smokeSteps.indexOf(runtimeGuardStep!)).toBeLessThan(smokeSteps.indexOf(appSmokeStep!)) expect(smokeSteps.indexOf(packagedSmokeStep!)).toBeGreaterThan(smokeSteps.indexOf(smokeStep!)) @@ -83,6 +89,10 @@ describe("desktop smoke workflow", () => { expect(smokeStep?.run).toContain("Expected app.asar at") expect(smokeStep?.run).toContain("Expected Electron Framework at") expect(smokeStep?.run).toContain("Expected helper app at") + expect(smokeStep?.run).toContain("Expected executable OfficeCLI at") + expect(smokeStep?.run).toContain('OFFICECLI_SKIP_UPDATE=1 "$OFFICECLI_PATH" --version') + expect(smokeStep?.run).toContain("THIRD_PARTY_NOTICES.md") + expect(smokeStep?.run).toContain("Expected third-party notices at") expect(smokeStep?.run).toContain("codesign -dv --verbose=2") expect(smokeStep?.run).toContain('grep -q "Signature=adhoc"') diff --git a/packages/opencode/test/tool/bash-env-source.test.ts b/packages/opencode/test/tool/bash-env-source.test.ts new file mode 100644 index 000000000..7c3813747 --- /dev/null +++ b/packages/opencode/test/tool/bash-env-source.test.ts @@ -0,0 +1,9 @@ +import { expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import path from "node:path" + +test("Bash tool disables OfficeCLI self-update for bundled tools", () => { + const source = readFileSync(path.join(import.meta.dir, "../../src/tool/bash.ts"), "utf8") + expect(source).toContain("OFFICECLI_SKIP_UPDATE") + expect(source).toContain('OFFICECLI_SKIP_UPDATE: "1"') +})