From a8170f8647bec8f5f55b7caaa554e260ced331aa Mon Sep 17 00:00:00 2001 From: l1shen <648952316@qq.com> Date: Mon, 12 Oct 2026 19:31:33 +0800 Subject: [PATCH] feat(flow): bundle Open Flow command with the CLI --- bun.lock | 4 +- contrib/ci/npm-packages.test.ts | 28 +- docs/commands.md | 24 +- docs/commands.zh-CN.md | 17 +- package.json | 2 +- .../commands/flow-artifact.test.ts | 479 ---------- src/application/commands/flow-artifact.ts | 875 ------------------ src/application/commands/flow-release.ts | 23 - src/application/commands/flow.cli.test.ts | 54 +- src/application/commands/flow.ts | 131 +-- .../commands/uninstall.cli.test.ts | 8 - src/application/self-update/uninstall.test.ts | 7 - src/application/self-update/uninstall.ts | 12 - src/i18n/catalog.ts | 8 - 14 files changed, 99 insertions(+), 1573 deletions(-) delete mode 100644 src/application/commands/flow-artifact.test.ts delete mode 100644 src/application/commands/flow-artifact.ts delete mode 100644 src/application/commands/flow-release.ts diff --git a/bun.lock b/bun.lock index 0bbfb761..725030cb 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "oo", "dependencies": { + "@oomol-lab/open-flow": "0.1.0-beta.61", "@vercel/detect-agent": "^1.2.5", "@wopjs/cast": "^0.1.16", "agentic-markdown": "^0.0.4", @@ -20,7 +21,6 @@ }, "devDependencies": { "@antfu/eslint-config": "^7.7.3", - "@oomol-lab/open-flow": "^0.1.0-beta.59", "@types/bun": "latest", "eslint": "^10.0.3", "eslint-plugin-format": "^2.0.1", @@ -110,7 +110,7 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], - "@oomol-lab/open-flow": ["@oomol-lab/open-flow@0.1.0-beta.59", "", { "peerDependencies": { "effect": "4.0.0-rc.112", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "zod": "^4.6.5" } }, "sha512-wXqV/N8gcEDvsuwJbSD8FkS+nyfCiY4ZckrTqnc0brCmiAANS40pVcPXmIj+KyCzFI9DegaGkS1CZWIXf+obGw=="], + "@oomol-lab/open-flow": ["@oomol-lab/open-flow@0.1.0-beta.61", "", { "peerDependencies": { "effect": "4.0.0-rc.112", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "zod": "^4.6.5" } }, "sha512-ybridw95CYrE0UFqQoXEJOxWh4qJrBXVmqomvI4pGnGHlz+PVIu2A9LmOb5D28lqAwsgftqEaDug6O15VSkCaw=="], "@ota-meshi/ast-token-store": ["@ota-meshi/ast-token-store@0.3.0", "", {}, "sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg=="], diff --git a/contrib/ci/npm-packages.test.ts b/contrib/ci/npm-packages.test.ts index 7fdd6bc5..8a7c1e42 100644 --- a/contrib/ci/npm-packages.test.ts +++ b/contrib/ci/npm-packages.test.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, readdir, readFile, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { version as openFlowVersion } from "@oomol-lab/open-flow/package.json"; import { describe, expect, test } from "bun:test"; import { createTemporaryDirectory } from "../../__tests__/helpers.ts"; import { @@ -464,7 +465,7 @@ describe("npm-packages", () => { }); compiledBinaryInstallTest( - "compiled binary installs bundled skills to stable file paths", + "compiled binary runs bundled Flow and installs bundled skills", async () => { const rootDirectoryPath = process.cwd(); const temporaryDirectoryPath = await createTemporaryDirectory( @@ -514,6 +515,31 @@ describe("npm-packages", () => { mkdir(homeDirectoryPath, { recursive: true }), ]); + for (const args of [[], ["--help"], ["--version"]]) { + const flowResult = Bun.spawnSync([executablePath, "flow", ...args], { + cwd: temporaryDirectoryPath, + env: { + ...process.env, + HOME: homeDirectoryPath, + XDG_CONFIG_HOME: configHomeDirectoryPath, + XDG_CACHE_HOME: join(temporaryDirectoryPath, "cache"), + LOCALAPPDATA: join(temporaryDirectoryPath, "local-app-data"), + OO_OPEN_FLOW_COMMAND_DIR: "", + OO_TELEMETRY_DISABLED: "1", + }, + stderr: "pipe", + stdin: "ignore", + stdout: "pipe", + timeout: 5_000, + }); + + expect(decodeOutput(flowResult.stderr)).toBe(""); + expect(flowResult.exitCode).toBe(0); + expect(decodeOutput(flowResult.stdout)).toContain( + args[0] === "--version" ? openFlowVersion : "oo flow", + ); + } + const installResult = Bun.spawnSync( [executablePath, "skills", "add"], { diff --git a/docs/commands.md b/docs/commands.md index fb796b63..b052f02b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -115,10 +115,9 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). ### `oo flow [args...]` -Run the Open Flow CLI release pinned by the current `oo` release. The first -invocation downloads and verifies its immutable command archive. Later -invocations reuse the verified local cache without checking for updates or -requiring network access. +Run the Open Flow CLI bundled with the current `oo` release, without any +additional download. Help and version queries work offline; operations against +a Hosted or self-hosted Server still require a connection to that service. - The `oo` host consumes `--team ` for the whole Flow invocation. Remaining arguments after `flow` are passed to Open Flow unchanged and are @@ -126,8 +125,7 @@ requiring network access. - The effective `oo --lang` locale is passed to Open Flow as `en` or `zh-CN`. Open Flow owns and versions its translated command text. - `oo flow --help` and `oo flow --version` are therefore Open Flow commands. - Use `oo help flow` to show the host-side command description without loading - Open Flow. + Use `oo help flow` to show the host-side command description. - Root help and generated shell completions always list `flow` for both Hosted and self-hosted deployments. - Main `oo` global options such as `--lang` and `--debug` must appear before @@ -137,18 +135,8 @@ requiring network access. environment, and signals. Its exit code becomes the `oo` exit code. - With no delegated arguments, Open Flow prints help and never waits for input, including when stdin and stdout are TTYs. -- The downloaded archive is accepted only when its length and SHA-256 digest - match the release pinned by `oo`, and its internal manifest and complete file - set agree with that release. The Bun build version recorded by the artifact - does not need to match the Bun runtime used by `oo`. -- Command Artifact v2 contains only the Control API CLI entry and license - files. It does not contain Workbench assets, a Deployment Runtime, skills, - or a local Project implementation. -- On a cache miss, interactive terminals show in-place byte progress on stderr; - non-interactive streams receive one start and one completion line. Cache hits - remain silent. -- `OO_OPEN_FLOW_COMMAND_DIR` bypasses download and cache resolution for local - repository integration testing. +- `OO_OPEN_FLOW_COMMAND_DIR` selects a local command entry for repository + integration testing. - By default, Open Flow commands use the current `oo` credential and effective Team. The Hosted gateway is derived from the current endpoint as `https://open-flow.`; for example, `OO_ENDPOINT=oomol.dev` uses diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 4c1c56d2..3395a87e 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -25,7 +25,7 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 使用指定的已展开命令产物目录。该目录必须包含 `entry.js`;Open Flow 仓库的标准构建会将它写到 `packages/command/dist/command/open-flow-command`。未设置时,`oo flow` - 使用当前 `oo` 版本固定的 Open Flow release。 + 使用当前 `oo` 版本内置的 Open Flow CLI。 - `OO_OPEN_FLOW_URL`:用完整 HTTP(S) origin 选择自部署 Open Flow Server,例如 `http://127.0.0.1:3000`。必须与 `OO_OPEN_FLOW_TOKEN` 同时设置;两者生效时, `oo flow` 不使用 OOMOL 账号、Team 或 `OO_ENDPOINT`。 @@ -95,15 +95,15 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 ### `oo flow [args...]` -运行当前 `oo` 版本固定的 Open Flow CLI release。首次调用会下载并验证对应的 -不可变命令归档;之后直接离线复用已验证的本地缓存,不会在每次启动时检查更新。 +运行随当前 `oo` 版本内置的 Open Flow CLI,无需额外下载。帮助和版本查询 +无需网络;访问 Hosted 或自部署 Server 的操作仍需要连接对应服务。 - `oo` 宿主读取 `--team `,为整个 Flow 调用选择团队。`flow` 后的 其余参数原样传给 Open Flow,并且不写入 debug 日志。 - 当前生效的 `oo --lang` locale 会以 `en` 或 `zh-CN` 传给 Open Flow; Open Flow 自己拥有并随版本发布对应的命令翻译文案。 - 因此 `oo flow --help` 和 `oo flow --version` 都属于 Open Flow 命令。 - 如需在不加载 Open Flow 的情况下查看宿主侧命令说明,请使用 `oo help flow`。 + 如需查看宿主侧命令说明,请使用 `oo help flow`。 - 根帮助和生成的 shell 补全始终会列出 `flow`,Hosted 与自部署模式一致。 - `--lang`、`--debug` 等 `oo` 全局选项必须放在 `flow` 前面; 团队选择也可以放在 `flow` 后或子命令后,其余选项归 Open Flow 所有。 @@ -111,14 +111,7 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 它的退出码会直接成为 `oo` 的退出码。 - 未传入后续参数时,Open Flow 只打印帮助且不等待输入;stdin 和 stdout 都是 TTY 时也保持相同行为。 -- 只有归档长度和 SHA-256 与 `oo` 固定的 release 一致,且内部 manifest 和完整 - 文件集合与该 release 相符时,下载内容才会被接受。Artifact 中记录的 Bun 构建 - 版本不需要与 `oo` 使用的 Bun runtime 版本一致。 -- Command Artifact v2 只包含 Control API CLI entry 和 license 文件,不包含 - Workbench assets、Deployment Runtime、skills 或本地 Project 实现。 -- 缓存未命中时,交互式终端会在 stderr 原地刷新字节进度;非交互式输出会分别打印 - 一行开始和完成信息。命中缓存时保持静默。 -- `OO_OPEN_FLOW_COMMAND_DIR` 仅用于本地仓库联调;设置后会跳过下载和缓存解析。 +- `OO_OPEN_FLOW_COMMAND_DIR` 仅用于本地仓库联调;设置后使用指定目录中的命令入口。 - 默认情况下,Open Flow 子命令使用当前 `oo` 登录凭证和生效的 Team。Hosted gateway 由当前 endpoint 派生为 `https://open-flow.`;例如 `OO_ENDPOINT=oomol.dev` 使用 `https://open-flow.oomol.dev`。 diff --git a/package.json b/package.json index 2ea36ca5..0152dbb3 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "typescript": "^5" }, "dependencies": { + "@oomol-lab/open-flow": "0.1.0-beta.61", "@vercel/detect-agent": "^1.2.5", "@wopjs/cast": "^0.1.16", "agentic-markdown": "^0.0.4", @@ -59,7 +60,6 @@ }, "devDependencies": { "@antfu/eslint-config": "^7.7.3", - "@oomol-lab/open-flow": "^0.1.0-beta.59", "@types/bun": "latest", "eslint": "^10.0.3", "eslint-plugin-format": "^2.0.1", diff --git a/src/application/commands/flow-artifact.test.ts b/src/application/commands/flow-artifact.test.ts deleted file mode 100644 index 85d937af..00000000 --- a/src/application/commands/flow-artifact.test.ts +++ /dev/null @@ -1,479 +0,0 @@ -import type { Fetcher } from "../contracts/cli.ts"; -import type { OpenFlowCommandRelease } from "./flow-release.ts"; - -import { createHash } from "node:crypto"; -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { gzipSync } from "node:zlib"; -import { afterEach, describe, expect, test } from "bun:test"; -import { requireAbortSignal } from "../../../__tests__/helpers.ts"; -import { - installOpenFlowCommandRelease, - resolveOpenFlowCommandCacheRoot, -} from "./flow-artifact.ts"; -import { openFlowCommandRelease } from "./flow-release.ts"; - -const temporaryDirectories = new Set(); -const textEncoder = new TextEncoder(); - -afterEach(async () => { - await Promise.all(Array.from(temporaryDirectories, path => - rm(path, { force: true, recursive: true }))); - temporaryDirectories.clear(); -}); - -describe("Open Flow command artifact", () => { - test("pins an immutable Open Flow command archive", () => { - const { archive, openFlowVersion } = openFlowCommandRelease; - - expect(archive.length).toBeGreaterThan(0); - expect(archive.url).toBe( - `https://static.oomol.com/release/apps/open-flow/command/open-flow-${openFlowVersion}-${archive.digest}.tar.gz`, - ); - }); - - test("reports one verified archive download and reuses its cache offline", async () => { - const fixture = createCommandReleaseFixture(); - const environment = await createTestEnvironment(); - const progress: number[] = []; - let requestCount = 0; - const fetcher = createArchiveFetcher(fixture.archive, () => { - requestCount += 1; - }); - const options = { - env: environment.env, - execPath: process.execPath, - fetcher, - onDownloadProgress: (downloadedBytes: number) => { - progress.push(downloadedBytes); - }, - }; - - const commandDirectory = await installOpenFlowCommandRelease( - fixture.release, - options, - ); - const cachedDirectory = await installOpenFlowCommandRelease( - fixture.release, - options, - ); - - expect(cachedDirectory).toBe(commandDirectory); - expect(requestCount).toBe(1); - expect(progress).toEqual([0, fixture.archive.byteLength]); - expect(await readFile(join(commandDirectory, "entry.js"), "utf8")) - .toBe(fixture.entrySource); - }); - - test("removes a corrupt cache entry and downloads the pinned archive again", async () => { - const fixture = createCommandReleaseFixture(); - const environment = await createTestEnvironment(); - let requestCount = 0; - const fetcher = createArchiveFetcher(fixture.archive, () => { - requestCount += 1; - }); - const options = { - env: environment.env, - execPath: process.execPath, - fetcher, - }; - const commandDirectory = await installOpenFlowCommandRelease( - fixture.release, - options, - ); - await writeFile(join(commandDirectory, "entry.js"), "corrupt\n"); - - const repairedDirectory = await installOpenFlowCommandRelease( - fixture.release, - options, - ); - - expect(repairedDirectory).toBe(commandDirectory); - expect(requestCount).toBe(2); - expect(await readFile(join(commandDirectory, "entry.js"), "utf8")) - .toBe(fixture.entrySource); - }); - - test("rejects archive bytes that do not match the pinned digest", async () => { - const fixture = createCommandReleaseFixture(); - const environment = await createTestEnvironment(); - const digest = "0".repeat(64); - const release = { - ...fixture.release, - archive: { - ...fixture.release.archive, - digest, - url: `https://static.example.test/open-flow-${fixture.release.openFlowVersion}-${digest}.tar.gz`, - }, - } satisfies OpenFlowCommandRelease; - - await expect(installOpenFlowCommandRelease(release, { - env: environment.env, - execPath: process.execPath, - fetcher: createArchiveFetcher(fixture.archive), - })).rejects.toThrow("digest does not match its release record"); - }); - - test("rejects links before writing any command artifact files", async () => { - const archive = encodeTarGzip([{ - body: new Uint8Array(), - mode: 0o644, - path: "open-flow-command/entry.js", - type: "2", - }]); - const release = createRelease(archive); - const environment = await createTestEnvironment(); - - await expect(installOpenFlowCommandRelease(release, { - env: environment.env, - execPath: process.execPath, - fetcher: createArchiveFetcher(archive), - })).rejects.toThrow("link, directory, device, metadata, or other non-file"); - }); - - for (const path of [ - "open-flow-command/../escape.js", - "open-flow-command//escape.js", - ]) { - test(`rejects unsafe tar path ${JSON.stringify(path)}`, async () => { - const archive = encodeTarGzip([{ - body: new Uint8Array(), - mode: 0o644, - path, - type: "0", - }]); - const release = createRelease(archive); - const environment = await createTestEnvironment(); - - await expect(installOpenFlowCommandRelease(release, { - env: environment.env, - execPath: process.execPath, - fetcher: createArchiveFetcher(archive), - })).rejects.toThrow("invalid file path"); - }); - } - - test("aborts timed out archive downloads and removes temporary files", async () => { - const fixture = createCommandReleaseFixture(); - const environment = await createTestEnvironment(); - const requestStarted = Promise.withResolvers(); - const timeoutScheduled = Promise.withResolvers<{ - advanceBy: (milliseconds: number) => void; - isCancelled: () => boolean; - }>(); - - const installation = installOpenFlowCommandRelease(fixture.release, { - env: environment.env, - execPath: process.execPath, - fetcher: (_input, init) => { - const signal = requireAbortSignal(init); - requestStarted.resolve(signal); - - return new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => { - reject(signal.reason); - }, { once: true }); - }); - }, - scheduleDownloadTimeout: (onTimeout, timeoutMs) => { - let elapsedMilliseconds = 0; - let isCancelled = false; - - timeoutScheduled.resolve({ - advanceBy: (milliseconds) => { - elapsedMilliseconds += milliseconds; - - if (!isCancelled && elapsedMilliseconds >= timeoutMs) { - onTimeout(); - } - }, - isCancelled: () => isCancelled, - }); - - return () => { - isCancelled = true; - }; - }, - }); - const [signal, fakeTimer] = await Promise.all([ - requestStarted.promise, - timeoutScheduled.promise, - ]); - - fakeTimer.advanceBy(299_999); - expect(signal.aborted).toBe(false); - - fakeTimer.advanceBy(1); - - await expect(installation).rejects.toBe(signal.reason); - expect(signal.aborted).toBe(true); - expect(fakeTimer.isCancelled()).toBe(true); - - const cacheRoot = resolveOpenFlowCommandCacheRoot({ - env: environment.env, - platform: process.platform, - }); - const temporaryEntries = (await readdir(cacheRoot, { recursive: true })) - .filter(entry => - entry.startsWith(".archive-") - || entry.startsWith(".extract-") - || entry.endsWith(".lock")); - - expect(temporaryEntries).toEqual([]); - }); - - test("accepts a valid archive from a different gzip compressor level", async () => { - const fixture = createCommandReleaseFixture(0); - const environment = await createTestEnvironment(); - - const commandDirectory = await installOpenFlowCommandRelease( - fixture.release, - { - env: environment.env, - execPath: process.execPath, - fetcher: createArchiveFetcher(fixture.archive), - }, - ); - - expect(await readFile(join(commandDirectory, "entry.js"), "utf8")) - .toBe(fixture.entrySource); - }); - - test("installs an artifact built with a different Bun version", async () => { - const fixture = createCommandReleaseFixture(9, "0.0.0"); - const environment = await createTestEnvironment(); - - const commandDirectory = await installOpenFlowCommandRelease( - fixture.release, - { - env: environment.env, - execPath: process.execPath, - fetcher: createArchiveFetcher(fixture.archive), - }, - ); - - expect(await readFile(join(commandDirectory, "entry.js"), "utf8")) - .toBe(fixture.entrySource); - }); - - test("serializes concurrent installation of the same digest", async () => { - const fixture = createCommandReleaseFixture(); - const environment = await createTestEnvironment(); - const requestStarted = Promise.withResolvers(); - const resumeRequest = Promise.withResolvers(); - let requestCount = 0; - const fetcher: Fetcher = async () => { - requestCount += 1; - requestStarted.resolve(); - await resumeRequest.promise; - return new Response(fixture.archive, { - headers: { - "content-length": String(fixture.archive.byteLength), - }, - }); - }; - const options = { - env: environment.env, - execPath: process.execPath, - fetcher, - }; - const first = installOpenFlowCommandRelease(fixture.release, options); - await requestStarted.promise; - const second = installOpenFlowCommandRelease(fixture.release, options); - await Bun.sleep(150); - - expect(requestCount).toBe(1); - resumeRequest.resolve(); - - const directories = await Promise.all([first, second]); - - expect(directories[0]).toBe(directories[1]); - expect(requestCount).toBe(1); - }); -}); - -function createCommandReleaseFixture(compressionLevel = 9, bunVersion = Bun.version): { - archive: Uint8Array; - entrySource: string; - release: OpenFlowCommandRelease; -} { - const entrySource = [ - "export const commandArtifactVersion = 2;", - "export async function runOpenFlowCommand() { return 0; }", - "", - ].join("\n"); - const payload = [ - { body: textEncoder.encode("Open Flow license\n"), path: "LICENSE" }, - { body: textEncoder.encode(entrySource), path: "entry.js" }, - ].toSorted((left, right) => left.path < right.path ? -1 : 1); - const files = payload.map(file => ({ - digest: sha256(file.body), - length: file.body.byteLength, - path: file.path, - })); - const manifest = `${JSON.stringify({ - bunVersion, - entry: "entry.js", - files, - format: "open-flow-command-artifact", - openFlowVersion: "1.2.3-test", - version: 2, - })}\n`; - const archiveEntries = [ - { - body: textEncoder.encode(manifest), - mode: 0o644, - path: "open-flow-command/command-artifact.json", - type: "0" as const, - }, - ...payload.map(file => ({ - ...file, - mode: file.path === "entry.js" ? 0o755 : 0o644, - path: `open-flow-command/${file.path}`, - type: "0" as const, - })), - ].toSorted((left, right) => left.path < right.path ? -1 : 1); - const archive = encodeTarGzip(archiveEntries, compressionLevel); - - return { - archive, - entrySource, - release: createRelease(archive, bunVersion), - }; -} - -function createRelease(archive: Uint8Array, bunVersion = Bun.version): OpenFlowCommandRelease { - const digest = sha256(archive); - const openFlowVersion = "1.2.3-test"; - - return { - archive: { - digest, - length: archive.byteLength, - url: `https://static.example.test/open-flow-${openFlowVersion}-${digest}.tar.gz`, - }, - bunVersion, - format: "open-flow-command-release", - openFlowVersion, - version: 1, - }; -} - -function createArchiveFetcher( - archive: Uint8Array, - onRequest: (init: RequestInit | undefined) => void = () => {}, -): Fetcher { - return (_input, init) => { - onRequest(init); - return Promise.resolve(new Response(archive, { - headers: { - "content-length": String(archive.byteLength), - }, - })); - }; -} - -async function createTestEnvironment(): Promise<{ - env: Record; -}> { - const root = await mkdtemp(join(tmpdir(), "oo-flow-artifact-")); - temporaryDirectories.add(root); - - return { - env: { - HOME: join(root, "home"), - LOCALAPPDATA: join(root, "local-app-data"), - USERPROFILE: join(root, "home"), - XDG_CACHE_HOME: join(root, "cache"), - }, - }; -} - -function encodeTarGzip(entries: readonly { - body: Uint8Array; - mode: number; - path: string; - type: "0" | "2"; -}[], compressionLevel = 9): Uint8Array { - const chunks: Uint8Array[] = []; - - for (const entry of entries) { - const header = new Uint8Array(512); - writeTarText(header, 0, 100, entry.path); - writeTarOctal(header, 100, 8, entry.mode); - writeTarOctal(header, 108, 8, 0); - writeTarOctal(header, 116, 8, 0); - writeTarOctal(header, 124, 12, entry.body.byteLength); - writeTarOctal(header, 136, 12, 0); - header.fill(0x20, 148, 156); - header[156] = entry.type.charCodeAt(0); - header.set(textEncoder.encode("ustar\0"), 257); - header.set(textEncoder.encode("00"), 263); - const checksum = header.reduce((sum, byte) => sum + byte, 0); - writeTarChecksum(header, checksum); - chunks.push(header, entry.body); - - const padding = (512 - entry.body.byteLength % 512) % 512; - - if (padding > 0) { - chunks.push(new Uint8Array(padding)); - } - } - - chunks.push(new Uint8Array(1024)); - const tar = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.byteLength, 0)); - let offset = 0; - - for (const chunk of chunks) { - tar.set(chunk, offset); - offset += chunk.byteLength; - } - - const archive = new Uint8Array(gzipSync(tar, { level: compressionLevel })); - archive[3] = 0; - archive[4] = 0; - archive[5] = 0; - archive[6] = 0; - archive[7] = 0; - archive[8] = 2; - archive[9] = 255; - return archive; -} - -function writeTarText( - header: Uint8Array, - offset: number, - length: number, - value: string, -): void { - const bytes = textEncoder.encode(value); - - if (bytes.byteLength >= length) { - throw new TypeError(`Tar fixture path is too long: ${value}`); - } - - header.set(bytes, offset); -} - -function writeTarOctal( - header: Uint8Array, - offset: number, - length: number, - value: number, -): void { - const source = value.toString(8).padStart(length - 1, "0"); - header.set(textEncoder.encode(source), offset); - header[offset + length - 1] = 0; -} - -function writeTarChecksum(header: Uint8Array, value: number): void { - const source = value.toString(8).padStart(6, "0"); - header.set(textEncoder.encode(source), 148); - header[154] = 0; - header[155] = 0x20; -} - -function sha256(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} diff --git a/src/application/commands/flow-artifact.ts b/src/application/commands/flow-artifact.ts deleted file mode 100644 index 1b77a44b..00000000 --- a/src/application/commands/flow-artifact.ts +++ /dev/null @@ -1,875 +0,0 @@ -import type { Fetcher } from "../contracts/cli.ts"; -import type { OpenFlowCommandRelease } from "./flow-release.ts"; - -import { createHash } from "node:crypto"; -import { mkdir, open, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; -import process from "node:process"; -import { gunzipSync } from "node:zlib"; -import { z } from "zod"; -import { resolveHomeDirectory } from "../path/home-directory.ts"; -import { acquireDownloadTempLock } from "../shared/download-temp-lock.ts"; - -const commandArtifactFormat = "open-flow-command-artifact"; -const commandArtifactVersion = 2; -const commandArtifactManifestFile = "command-artifact.json"; -const commandArtifactEntryFile = "entry.js"; -const commandArchiveRoot = "open-flow-command"; -const commandArchiveMediaType = "application/vnd.open-flow.command-artifact+tar+gzip"; -const archivePrefix = `${commandArchiveRoot}/`; -const cacheFormatDirectory = "command-artifact-v2"; -const tarBlockSize = 512; -const tarEndMarkerSize = tarBlockSize * 2; -const gzipHeaderSize = 10; -const gzipFooterSize = 8; -const lockWaitTimeoutMs = 300_000; -const commandArchiveDownloadTimeoutMs = 300_000; -const textDecoder = new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: false, -}); -const textEncoder = new TextEncoder(); -const ustarMagic = textEncoder.encode("ustar\0"); -const ustarVersion = textEncoder.encode("00"); - -interface CommandArtifactFile { - readonly digest: string; - readonly length: number; - readonly path: string; -} - -interface CommandArchiveEntry { - readonly bytes: Uint8Array; - readonly mode: number; - readonly path: string; -} - -const manifestFileSchema = z.object({ - digest: z.string().refine(isSha256Digest, "Command artifact file digest must be a lowercase SHA-256 digest."), - length: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), - path: z.string().refine(isNormalizedArtifactPath, "Command artifact file paths must be normalized relative paths."), -}).strict(); - -const commandArtifactManifestSchema = z.object({ - bunVersion: z.string().min(1), - entry: z.literal(commandArtifactEntryFile), - files: z.array(manifestFileSchema), - format: z.literal(commandArtifactFormat), - openFlowVersion: z.string().min(1), - version: z.literal(commandArtifactVersion), -}).strict().superRefine((manifest, context) => { - let previousPath: string | undefined; - - for (const [index, file] of manifest.files.entries()) { - if (file.path === commandArtifactManifestFile) { - context.addIssue({ - code: "custom", - message: "The command artifact manifest cannot list itself.", - path: ["files", index, "path"], - }); - } - - if ( - previousPath !== undefined - && compareArtifactPaths(previousPath, file.path) >= 0 - ) { - context.addIssue({ - code: "custom", - message: "Command artifact files must have unique paths in Unicode code-point order.", - path: ["files", index, "path"], - }); - } - - previousPath = file.path; - } - - if (!manifest.files.some(file => file.path === commandArtifactEntryFile)) { - context.addIssue({ - code: "custom", - message: `Command artifact is missing required file ${JSON.stringify(commandArtifactEntryFile)}.`, - path: ["files"], - }); - } -}); - -type CommandArtifactManifest = z.infer; - -export async function installOpenFlowCommandRelease( - release: OpenFlowCommandRelease, - options: { - env: Record; - execPath: string; - fetcher: Fetcher; - onDownloadProgress?: (downloadedBytes: number) => void; - scheduleDownloadTimeout?: ( - onTimeout: () => void, - timeoutMs: number, - ) => () => void; - }, -): Promise { - const cacheRoot = resolveOpenFlowCommandCacheRoot({ - env: options.env, - platform: process.platform, - }); - const commandDirectory = join(cacheRoot, release.archive.digest); - - if (await validCommandArtifactDirectory(commandDirectory, release)) { - return commandDirectory; - } - - await mkdir(cacheRoot, { recursive: true }); - const lockFilePath = join(cacheRoot, ".locks", `${release.archive.digest}.lock`); - await mkdir(dirname(lockFilePath), { recursive: true }); - const sessionId = Bun.randomUUIDv7(); - const lock = await waitForArtifactLock({ - archiveName: basename(new URL(release.archive.url).pathname), - execPath: options.execPath, - lockFilePath, - sessionId, - }); - - try { - if (await validCommandArtifactDirectory(commandDirectory, release)) { - return commandDirectory; - } - - await rm(commandDirectory, { force: true, recursive: true }); - - const temporaryId = `${process.pid}-${Bun.randomUUIDv7()}`; - const archivePath = join(cacheRoot, `.archive-${temporaryId}.tar.gz`); - const extractionDirectory = join(cacheRoot, `.extract-${temporaryId}`); - - try { - await downloadCommandArchive( - release, - options.fetcher, - archivePath, - options.onDownloadProgress, - options.scheduleDownloadTimeout ?? scheduleDownloadTimeout, - ); - const archive = await readFile(archivePath); - const decoded = decodeCommandArchive(archive, release); - await writeCommandArtifactDirectory(extractionDirectory, decoded); - await validateCommandArtifactDirectory(extractionDirectory, release); - await rename(extractionDirectory, commandDirectory); - } - finally { - await Promise.all([ - rm(archivePath, { force: true }), - rm(extractionDirectory, { force: true, recursive: true }), - ]); - } - - return commandDirectory; - } - finally { - await lock.close(); - } -} - -async function waitForArtifactLock(options: { - archiveName: string; - execPath: string; - lockFilePath: string; - sessionId: string; -}): Promise>, { status: "acquired" }>["handle"]> { - const deadline = Date.now() + lockWaitTimeoutMs; - - while (Date.now() < deadline) { - const result = await acquireDownloadTempLock({ - execPath: options.execPath, - lockFilePath: options.lockFilePath, - sessionId: options.sessionId, - tempFileName: options.archiveName, - }); - - if (result.status === "acquired") { - return result.handle; - } - - await Bun.sleep(100); - } - - invalid("Timed out waiting for another Open Flow command artifact installation."); -} - -async function downloadCommandArchive( - release: OpenFlowCommandRelease, - fetcher: Fetcher, - archivePath: string, - onProgress: ((downloadedBytes: number) => void) | undefined, - scheduleTimeout: ( - onTimeout: () => void, - timeoutMs: number, - ) => () => void, -): Promise { - onProgress?.(0); - const abortController = new AbortController(); - const cancelTimeout = scheduleTimeout(() => { - abortController.abort(new DOMException( - "The operation was aborted due to timeout", - "TimeoutError", - )); - }, commandArchiveDownloadTimeoutMs); - - try { - const response = await fetcher(release.archive.url, { - headers: { - accept: commandArchiveMediaType, - }, - signal: abortController.signal, - }); - - if (!response.ok) { - invalid(`Open Flow command archive request failed with status ${response.status}.`); - } - - const declaredLength = response.headers.get("content-length"); - - if (declaredLength !== null) { - const parsedLength = Number(declaredLength); - - if ( - !Number.isSafeInteger(parsedLength) - || parsedLength < 0 - || parsedLength !== release.archive.length - ) { - invalid("Open Flow command archive response length does not match its release record."); - } - } - - if (response.body === null) { - invalid("Open Flow command archive response has no body."); - } - - const fileHandle = await open(archivePath, "wx"); - const digest = createHash("sha256"); - const reader = response.body.getReader(); - let length = 0; - - try { - while (true) { - const chunk = await reader.read(); - - if (chunk.done) { - break; - } - - if (length + chunk.value.byteLength > release.archive.length) { - invalid("Open Flow command archive is longer than its release record."); - } - - let written = 0; - - while (written < chunk.value.byteLength) { - const result = await fileHandle.write( - chunk.value, - written, - chunk.value.byteLength - written, - length + written, - ); - - if (result.bytesWritten === 0) { - invalid("Open Flow command archive download stopped before completion."); - } - - written += result.bytesWritten; - } - - digest.update(chunk.value); - length += chunk.value.byteLength; - onProgress?.(length); - } - - await fileHandle.sync(); - } - finally { - reader.releaseLock(); - await fileHandle.close(); - } - - if (length !== release.archive.length) { - invalid("Open Flow command archive length does not match its release record."); - } - - if (digest.digest("hex") !== release.archive.digest) { - invalid("Open Flow command archive digest does not match its release record."); - } - } - finally { - cancelTimeout(); - } -} - -function scheduleDownloadTimeout( - onTimeout: () => void, - timeoutMs: number, -): () => void { - const timeoutId = setTimeout(onTimeout, timeoutMs); - return () => clearTimeout(timeoutId); -} - -function decodeCommandArchive( - archive: Uint8Array, - release: OpenFlowCommandRelease, -): readonly CommandArchiveEntry[] { - const tar = decodeCanonicalGzip(archive); - const files = decodeTar(tar); - const manifestFile = files.find(file => file.path === commandArtifactManifestFile); - - if (manifestFile === undefined) { - invalid(`Command archive is missing ${commandArtifactManifestFile}.`); - } - - const manifest = decodeCommandArtifactManifest(manifestFile.bytes); - validateManifestRelease(manifest, release); - const payloadFiles = files.filter(file => file.path !== commandArtifactManifestFile); - - if (payloadFiles.length !== manifest.files.length) { - invalid("Command archive file set does not match its manifest."); - } - - for (const [index, file] of payloadFiles.entries()) { - const expected = manifest.files[index]; - - if (expected === undefined || file.path !== expected.path) { - invalid("Command archive file set does not match its manifest."); - } - - validateFile(file.bytes, expected); - } - - return files; -} - -function decodeCanonicalGzip(archive: Uint8Array): Uint8Array { - if ( - archive.byteLength < gzipHeaderSize + gzipFooterSize - || archive[0] !== 0x1F - || archive[1] !== 0x8B - || archive[2] !== 8 - || archive[3] !== 0 - || archive[4] !== 0 - || archive[5] !== 0 - || archive[6] !== 0 - || archive[7] !== 0 - || archive[8] !== 2 - || archive[9] !== 255 - ) { - invalid("Command archive does not have the canonical gzip header."); - } - - let tar: Uint8Array; - - try { - tar = new Uint8Array(gunzipSync(archive)); - } - catch (error) { - throw new TypeError("Command archive gzip stream cannot be decoded.", { - cause: error, - }); - } - - return tar; -} - -function decodeTar(tar: Uint8Array): readonly CommandArchiveEntry[] { - if (tar.byteLength < tarEndMarkerSize || tar.byteLength % tarBlockSize !== 0) { - invalid("Command archive is not a complete block-aligned tar stream."); - } - - const files: CommandArchiveEntry[] = []; - let offset = 0; - let previousPath: string | undefined; - - while (offset < tar.byteLength) { - if (zeroBytes(tar, offset, tarBlockSize)) { - if ( - offset + tarEndMarkerSize !== tar.byteLength - || !zeroBytes(tar, offset + tarBlockSize, tarBlockSize) - ) { - invalid("Command archive has an invalid tar end marker."); - } - - return files; - } - - validateTarChecksum(tar, offset); - - if ( - !sameBytes(tar.subarray(offset + 257, offset + 263), ustarMagic) - || !sameBytes(tar.subarray(offset + 263, offset + 265), ustarVersion) - ) { - invalid("Command archive contains a non-USTAR entry."); - } - - if (tar[offset + 156] !== 0x30) { - invalid("Command archive contains a link, directory, device, metadata, or other non-file tar entry."); - } - - const name = decodeTarText(tar, offset, 100, "name"); - const prefix = decodeTarText(tar, offset + 345, 155, "prefix"); - const archivePath = prefix === "" ? name : `${prefix}/${name}`; - - if (!archivePath.startsWith(archivePrefix)) { - invalid(`Command archive entry is outside ${archivePrefix}.`); - } - - const path = archivePath.slice(archivePrefix.length); - - if (!isNormalizedArtifactPath(path)) { - invalid(`Command archive contains an invalid file path: ${archivePath}`); - } - - if ( - previousPath !== undefined - && compareArtifactPaths(previousPath, path) >= 0 - ) { - invalid("Command archive paths are not sorted uniquely."); - } - - const mode = decodeTarOctal(tar, offset + 100, 8, "mode"); - - if ( - mode !== modeForArtifactPath(path) - || decodeTarOctal(tar, offset + 108, 8, "uid") !== 0 - || decodeTarOctal(tar, offset + 116, 8, "gid") !== 0 - || decodeTarOctal(tar, offset + 136, 12, "mtime") !== 0 - || decodeTarText(tar, offset + 157, 100, "linkname") !== "" - || decodeTarText(tar, offset + 265, 32, "uname") !== "" - || decodeTarText(tar, offset + 297, 32, "gname") !== "" - || decodeOptionalTarOctal(tar, offset + 329, 8, "devmajor") !== 0 - || decodeOptionalTarOctal(tar, offset + 337, 8, "devminor") !== 0 - || !zeroBytes(tar, offset + 500, 12) - ) { - invalid(`Command archive entry has invalid metadata: ${archivePath}`); - } - - const size = decodeTarOctal(tar, offset + 124, 12, "size"); - const bodyStart = offset + tarBlockSize; - const bodyEnd = bodyStart + size; - const nextOffset = bodyStart + Math.ceil(size / tarBlockSize) * tarBlockSize; - - if (bodyEnd > tar.byteLength - tarEndMarkerSize || nextOffset > tar.byteLength - tarEndMarkerSize) { - invalid(`Command archive contains a truncated file: ${archivePath}`); - } - - if (!zeroBytes(tar, bodyEnd, nextOffset - bodyEnd)) { - invalid(`Command archive file has non-zero tar padding: ${archivePath}`); - } - - files.push({ - bytes: tar.subarray(bodyStart, bodyEnd), - mode, - path, - }); - offset = nextOffset; - previousPath = path; - } - - invalid("Command archive is missing its tar end marker."); -} - -function validateTarChecksum(tar: Uint8Array, offset: number): void { - const expected = decodeTarOctal(tar, offset + 148, 8, "checksum"); - let actual = 0; - - for (let index = 0; index < tarBlockSize; index += 1) { - actual += index >= 148 && index < 156 - ? 0x20 - : tar[offset + index] ?? 0; - } - - if (actual !== expected) { - invalid("Command archive contains a tar header with an invalid checksum."); - } -} - -function decodeTarText( - tar: Uint8Array, - offset: number, - length: number, - field: string, -): string { - const bytes = tar.subarray(offset, offset + length); - let end = bytes.indexOf(0); - - if (end < 0) { - end = bytes.length; - } - else if (!zeroBytes(bytes, end, bytes.length - end)) { - invalid(`Command archive contains a non-canonical tar ${field} field.`); - } - - try { - return textDecoder.decode(bytes.subarray(0, end)); - } - catch (error) { - throw new TypeError(`Command archive tar ${field} is not valid UTF-8.`, { - cause: error, - }); - } -} - -function decodeTarOctal( - tar: Uint8Array, - offset: number, - length: number, - field: string, -): number { - const bytes = tar.subarray(offset, offset + length); - let digits = ""; - let terminated = false; - - for (const byte of bytes) { - if (byte === 0 || byte === 0x20) { - terminated = true; - continue; - } - - if (terminated || byte < 0x30 || byte > 0x37) { - invalid(`Command archive contains a non-canonical tar ${field} field.`); - } - - digits += String.fromCharCode(byte); - } - - if (digits === "") { - invalid(`Command archive contains an empty tar ${field} field.`); - } - - const value = Number.parseInt(digits, 8); - - if (!Number.isSafeInteger(value)) { - invalid(`Command archive contains an unsupported tar ${field} value.`); - } - - return value; -} - -function decodeOptionalTarOctal( - tar: Uint8Array, - offset: number, - length: number, - field: string, -): number { - return zeroBytes(tar, offset, length) - ? 0 - : decodeTarOctal(tar, offset, length, field); -} - -function decodeCommandArtifactManifest(bytes: Uint8Array): CommandArtifactManifest { - let source: string; - - try { - source = textDecoder.decode(bytes); - } - catch (error) { - throw new TypeError(`${commandArtifactManifestFile} is not valid UTF-8.`, { - cause: error, - }); - } - - let value: unknown; - - try { - value = JSON.parse(source); - } - catch (error) { - throw new TypeError(`${commandArtifactManifestFile} is not valid JSON.`, { - cause: error, - }); - } - - const result = commandArtifactManifestSchema.safeParse(value); - - if (!result.success) { - throw new TypeError(`${commandArtifactManifestFile} is invalid.`, { - cause: result.error, - }); - } - - if (`${stringifyCanonicalJson(result.data)}\n` !== source) { - invalid(`${commandArtifactManifestFile} is not canonical.`); - } - - return result.data; -} - -async function writeCommandArtifactDirectory( - directory: string, - files: readonly CommandArchiveEntry[], -): Promise { - await Promise.all(files.map(async (file) => { - const path = join(directory, ...file.path.split("/")); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, file.bytes, { mode: file.mode }); - })); -} - -async function validCommandArtifactDirectory( - directory: string, - release: OpenFlowCommandRelease, -): Promise { - try { - await validateCommandArtifactDirectory(directory, release); - return true; - } - catch { - return false; - } -} - -async function validateCommandArtifactDirectory( - directory: string, - release: OpenFlowCommandRelease, -): Promise { - const manifest = decodeCommandArtifactManifest( - await readFile(join(directory, commandArtifactManifestFile)), - ); - validateManifestRelease(manifest, release); - const actual = await collectArtifactDirectory(directory); - const expectedFiles = [ - commandArtifactManifestFile, - ...manifest.files.map(file => file.path), - ].toSorted(compareArtifactPaths); - const expectedDirectories = collectExpectedDirectories(expectedFiles); - - if ( - !sameStrings(actual.files, expectedFiles) - || !sameStrings(actual.directories, expectedDirectories) - ) { - invalid("Command artifact cache file set does not match its manifest."); - } - - await Promise.all(manifest.files.map(async (file) => { - const bytes = await readFile(join(directory, ...file.path.split("/"))); - validateFile(bytes, file); - })); -} - -async function collectArtifactDirectory( - directory: string, - prefix = "", -): Promise<{ directories: string[]; files: string[] }> { - const entries = await readdir(directory, { withFileTypes: true }); - const directories: string[] = []; - const files: string[] = []; - - for (const entry of entries) { - const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`; - - if (!isNormalizedArtifactPath(path)) { - invalid(`Command artifact cache contains an invalid path: ${path}`); - } - - if (entry.isFile()) { - files.push(path); - continue; - } - - if (!entry.isDirectory()) { - invalid(`Command artifact cache contains a non-file entry: ${path}`); - } - - directories.push(path); - const nested = await collectArtifactDirectory( - join(directory, entry.name), - path, - ); - directories.push(...nested.directories); - files.push(...nested.files); - } - - return { - directories: directories.toSorted(compareArtifactPaths), - files: files.toSorted(compareArtifactPaths), - }; -} - -function collectExpectedDirectories(files: readonly string[]): string[] { - const directories = new Set(); - - for (const file of files) { - const parts = file.split("/"); - - for (let index = 1; index < parts.length; index += 1) { - directories.add(parts.slice(0, index).join("/")); - } - } - - return [...directories].toSorted(compareArtifactPaths); -} - -function validateManifestRelease( - manifest: CommandArtifactManifest, - release: OpenFlowCommandRelease, -): void { - if ( - manifest.openFlowVersion !== release.openFlowVersion - || manifest.bunVersion !== release.bunVersion - ) { - invalid("Command artifact manifest does not match its release record."); - } -} - -function validateFile(bytes: Uint8Array, expected: CommandArtifactFile): void { - if (bytes.byteLength !== expected.length) { - invalid(`Command artifact file length does not match its manifest: ${expected.path}`); - } - - if (sha256(bytes) !== expected.digest) { - invalid(`Command artifact file digest does not match its manifest: ${expected.path}`); - } -} - -export function resolveOpenFlowCommandCacheRoot(options: { - env: Record; - homeDirectory?: string; - platform: NodeJS.Platform; -}): string { - const homeDirectory = resolveHomeDirectory( - options.env, - options.homeDirectory, - ); - let platformCacheRoot: string; - - switch (options.platform) { - case "darwin": - platformCacheRoot = join(homeDirectory, "Library", "Caches"); - break; - case "win32": - platformCacheRoot = options.env.LOCALAPPDATA - ?? join(homeDirectory, "AppData", "Local"); - break; - default: - platformCacheRoot = options.env.XDG_CACHE_HOME - ?? join(homeDirectory, ".cache"); - } - - return join(platformCacheRoot, "oo", "open-flow", cacheFormatDirectory); -} - -function isSha256Digest(value: string): boolean { - if (value.length !== 64) { - return false; - } - - for (const character of value) { - if ( - !(character >= "0" && character <= "9") - && !(character >= "a" && character <= "f") - ) { - return false; - } - } - - return true; -} - -function isNormalizedArtifactPath(path: string): boolean { - if ( - path === "" - || !path.isWellFormed() - || path.startsWith("/") - || path.includes("\\") - || path.includes("\0") - ) { - return false; - } - - const first = path[0]; - - if ( - first !== undefined - && path[1] === ":" - && path[2] === "/" - && ((first >= "A" && first <= "Z") || (first >= "a" && first <= "z")) - ) { - return false; - } - - return path.split("/").every(part => part !== "" && part !== "." && part !== ".."); -} - -function compareArtifactPaths(left: string, right: string): number { - let leftIndex = 0; - let rightIndex = 0; - - while (leftIndex < left.length && rightIndex < right.length) { - const leftCodePoint = left.codePointAt(leftIndex); - const rightCodePoint = right.codePointAt(rightIndex); - - if (leftCodePoint === undefined || rightCodePoint === undefined) { - break; - } - - if (leftCodePoint !== rightCodePoint) { - return leftCodePoint < rightCodePoint ? -1 : 1; - } - - leftIndex += leftCodePoint > 0xFFFF ? 2 : 1; - rightIndex += rightCodePoint > 0xFFFF ? 2 : 1; - } - - return leftIndex < left.length ? 1 : rightIndex < right.length ? -1 : 0; -} - -function stringifyCanonicalJson(value: unknown): string { - if (value === null || typeof value === "boolean" || typeof value === "string") { - return JSON.stringify(value); - } - - if (typeof value === "number") { - if (!Number.isFinite(value)) { - invalid("Command artifact manifests cannot contain non-finite numbers."); - } - - return JSON.stringify(Object.is(value, -0) ? 0 : value); - } - - if (Array.isArray(value)) { - return `[${value.map(stringifyCanonicalJson).join(",")}]`; - } - - if (typeof value === "object") { - return `{${Object.keys(value) - .toSorted(compareArtifactPaths) - .map(key => `${JSON.stringify(key)}:${stringifyCanonicalJson(Reflect.get(value, key))}`) - .join(",")}}`; - } - - invalid(`Command artifact manifests cannot contain ${typeof value} values.`); -} - -function modeForArtifactPath(path: string): number { - return path === commandArtifactEntryFile ? 0o755 : 0o644; -} - -function sha256(bytes: Uint8Array): string { - return createHash("sha256").update(bytes).digest("hex"); -} - -function sameBytes(left: Uint8Array, right: Uint8Array): boolean { - return left.byteLength === right.byteLength - && left.every((byte, index) => byte === right[index]); -} - -function sameStrings(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length - && left.every((value, index) => value === right[index]); -} - -function zeroBytes(bytes: Uint8Array, offset: number, length: number): boolean { - for (let index = offset; index < offset + length; index += 1) { - if (bytes[index] !== 0) { - return false; - } - } - - return true; -} - -function invalid(message: string): never { - throw new TypeError(message); -} diff --git a/src/application/commands/flow-release.ts b/src/application/commands/flow-release.ts deleted file mode 100644 index 68af96a3..00000000 --- a/src/application/commands/flow-release.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface OpenFlowCommandRelease { - readonly archive: { - readonly digest: string; - readonly length: number; - readonly url: string; - }; - readonly bunVersion: string; - readonly format: "open-flow-command-release"; - readonly openFlowVersion: string; - readonly version: 1; -} - -export const openFlowCommandRelease = { - archive: { - digest: "cab2ca0199adc759ef48afc68e125908ea7df24ef6ee28c85de889a332ee4646", - length: 81_840, - url: "https://static.oomol.com/release/apps/open-flow/command/open-flow-0.1.0-beta.58-cab2ca0199adc759ef48afc68e125908ea7df24ef6ee28c85de889a332ee4646.tar.gz", - }, - bunVersion: "1.4.2", - format: "open-flow-command-release", - openFlowVersion: "0.1.0-beta.58", - version: 1, -} as const satisfies OpenFlowCommandRelease; diff --git a/src/application/commands/flow.cli.test.ts b/src/application/commands/flow.cli.test.ts index eccbd1da..fd4b4555 100644 --- a/src/application/commands/flow.cli.test.ts +++ b/src/application/commands/flow.cli.test.ts @@ -1,7 +1,9 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import process from "node:process"; -import { describe, expect, test } from "bun:test"; +import { version as openFlowVersion } from "@oomol-lab/open-flow/package.json"; +import { describe, expect, spyOn, test } from "bun:test"; import { createCliSandbox, createTemporaryDirectory, @@ -12,9 +14,7 @@ import { } from "../../../__tests__/helpers.ts"; import { APP_NAME } from "../config/app-config.ts"; import { parseTelemetryRowPayload, readTelemetryRowsForTest } from "../telemetry/outbox.ts"; -import { openFlowCommandRelease } from "./flow-release.ts"; import { resolveOpenFlowInvocation } from "./flow.ts"; -import { formatByteCount } from "./shared/download-progress.ts"; describe("flow CLI", () => { test("recognizes flow after oo global options without consuming delegated options", () => { @@ -844,44 +844,30 @@ describe("flow CLI", () => { } }); - test("reports when the pinned Open Flow release cannot be downloaded", async () => { - const sandbox = await createCliSandbox(); - - try { - const result = await sandbox.run(["flow", "--help"], { - fetcher: () => Promise.resolve(new Response(null, { status: 404 })), - }); - - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain( - `Downloading Open Flow ${openFlowCommandRelease.openFlowVersion}...`, - ); - expect(result.stderr).toContain( - `Open Flow ${openFlowCommandRelease.openFlowVersion} could not be downloaded or verified.`, - ); - } - finally { - await sandbox.cleanup(); - } - }); - - test("renders byte progress while downloading in an interactive terminal", async () => { + test.each([{ args: [] }, { args: ["--help"] }, { args: ["--version"] }])("runs the bundled command offline with arguments %j", async ({ args }) => { const sandbox = await createCliSandbox(); + let output = ""; + let requestCount = 0; + const stdout = spyOn(process.stdout, "write").mockImplementation((chunk) => { + output += String(chunk); + return true; + }); try { - const result = await sandbox.run(["flow", "--help"], { - fetcher: () => Promise.resolve(new Response(null, { status: 404 })), - stderr: { isTTY: true }, + const result = await sandbox.run(["flow", ...args], { + fetcher: () => { + requestCount += 1; + throw new Error("Network unavailable"); + }, }); - expect(result.exitCode).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain( - `Downloading Open Flow ${openFlowCommandRelease.openFlowVersion}: 0 B / ${formatByteCount(openFlowCommandRelease.archive.length)} (0%)`, - ); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requestCount).toBe(0); + expect(output).toContain(args[0] === "--version" ? openFlowVersion : "oo flow"); } finally { + stdout.mockRestore(); await sandbox.cleanup(); } }); diff --git a/src/application/commands/flow.ts b/src/application/commands/flow.ts index 5cc568ac..da49d330 100644 --- a/src/application/commands/flow.ts +++ b/src/application/commands/flow.ts @@ -2,14 +2,12 @@ import type { CliCommandDefinition, CliExecutionContext } from "../contracts/cli import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; +import { runOpenFlowCommand as runBundledOpenFlowCommand } from "@oomol-lab/open-flow/command"; import { resolveRequestLanguage } from "../../i18n/locale.ts"; import { readDefaultTeam } from "../auth/default-team.ts"; import { readTrimmedEnv, requireIdentity } from "../auth/identity.ts"; import { CliUserError } from "../contracts/cli.ts"; import { createBrowserSignIn } from "./auth/web.ts"; -import { installOpenFlowCommandRelease } from "./flow-artifact.ts"; -import { openFlowCommandRelease } from "./flow-release.ts"; -import { createDownloadProgressReporter } from "./shared/download-progress.ts"; import { readTeamFlag, requireValidTeamIdentity, @@ -123,11 +121,9 @@ export async function runOpenFlowCommand( | "connectorStore" | "cwd" | "env" - | "execPath" | "fetcher" | "logger" | "settingsStore" - | "stderr" | "teamFlag" | "telemetry" | "translator" @@ -165,93 +161,55 @@ export async function runOpenFlowCommand( const hostContext = { ...context, teamFlag: readTeamFlag({ team }) }; const configuredDirectory = context.env[commandDirectoryEnvName]?.trim(); - let commandDirectory: string; + let runCommand = runBundledOpenFlowCommand; if (configuredDirectory) { - commandDirectory = resolve(context.cwd, configuredDirectory); - } - else { - const progressReporter = createDownloadProgressReporter( - context.stderr, - openFlowCommandRelease.archive.length, - `Open Flow ${openFlowCommandRelease.openFlowVersion}`, - ); - let downloadStarted = false; - let downloadedBytes = 0; + const entryPath = join(resolve(context.cwd, configuredDirectory), "entry.js"); + let loaded: unknown; try { - commandDirectory = await installOpenFlowCommandRelease(openFlowCommandRelease, { - env: context.env, - execPath: context.execPath, - fetcher: context.fetcher, - onDownloadProgress(nextDownloadedBytes) { - if (!downloadStarted && progressReporter === undefined) { - context.stderr.write( - `${context.translator.t("flow.download.start", { - version: openFlowCommandRelease.openFlowVersion, - })}\n`, - ); - } - - downloadStarted = true; - downloadedBytes = nextDownloadedBytes; - progressReporter?.render(downloadedBytes); - }, - }); - - if (downloadStarted) { - if (progressReporter === undefined) { - context.stderr.write( - `${context.translator.t("flow.download.complete", { - version: openFlowCommandRelease.openFlowVersion, - })}\n`, - ); - } - else { - progressReporter.complete(downloadedBytes); - } - } + loaded = await import(pathToFileURL(entryPath).href); } catch (error) { - if (downloadStarted) { - progressReporter?.finish(downloadedBytes); - } - - context.logger.debug({ err: error }, "Open Flow command artifact preparation failed."); - throw new CliUserError("errors.flow.commandArtifactUnavailable", 1, { - version: openFlowCommandRelease.openFlowVersion, + throw new CliUserError("errors.flow.commandEntryLoadFailed", 1, { + message: error instanceof Error ? error.message : String(error), + path: entryPath, }); } - } - const entryPath = join(commandDirectory, "entry.js"); - let loaded: unknown; + if (loaded === null || typeof loaded !== "object") { + throw new CliUserError("errors.flow.commandEntryInvalid", 1, { + path: entryPath, + }); + } - try { - loaded = await import(pathToFileURL(entryPath).href); - } - catch (error) { - throw new CliUserError("errors.flow.commandEntryLoadFailed", 1, { - message: error instanceof Error ? error.message : String(error), - path: entryPath, - }); - } + const commandModule = loaded as CommandModule; - if (loaded === null || typeof loaded !== "object") { - throw new CliUserError("errors.flow.commandEntryInvalid", 1, { - path: entryPath, - }); - } + if ( + commandModule.commandArtifactVersion !== commandArtifactVersion + || typeof commandModule.runOpenFlowCommand !== "function" + ) { + throw new CliUserError("errors.flow.commandEntryInvalid", 1, { + path: entryPath, + }); + } + const runLocalCommand = commandModule.runOpenFlowCommand; + runCommand = async (args, host) => { + const exitCode = await runLocalCommand(args, host); - const commandModule = loaded as CommandModule; + if ( + typeof exitCode !== "number" + || !Number.isInteger(exitCode) + || exitCode < 0 + || exitCode > 255 + ) { + throw new CliUserError("errors.flow.commandEntryInvalid", 1, { + path: entryPath, + }); + } - if ( - commandModule.commandArtifactVersion !== commandArtifactVersion - || typeof commandModule.runOpenFlowCommand !== "function" - ) { - throw new CliUserError("errors.flow.commandEntryInvalid", 1, { - path: entryPath, - }); + return exitCode; + }; } // Resolved once per invocation and shared by both host methods, so the @@ -336,20 +294,7 @@ export async function runOpenFlowCommand( }, language: resolveRequestLanguage(context.translator.locale), }; - const exitCode = await commandModule.runOpenFlowCommand(commandArgs, host); - - if ( - typeof exitCode !== "number" - || !Number.isInteger(exitCode) - || exitCode < 0 - || exitCode > 255 - ) { - throw new CliUserError("errors.flow.commandEntryInvalid", 1, { - path: entryPath, - }); - } - - return exitCode; + return await runCommand(commandArgs, host); } // The command artifact reports any error a host method throws as diff --git a/src/application/commands/uninstall.cli.test.ts b/src/application/commands/uninstall.cli.test.ts index 1ba7d4ac..af6bde7d 100644 --- a/src/application/commands/uninstall.cli.test.ts +++ b/src/application/commands/uninstall.cli.test.ts @@ -9,7 +9,6 @@ import { resolveStorePaths } from "../../adapters/store/store-path.ts"; import { APP_NAME } from "../config/app-config.ts"; import { resolveSelfUpdatePaths } from "../self-update/paths.ts"; import { pathExists } from "../shared/fs-utils.ts"; -import { resolveOpenFlowCommandCacheRoot } from "./flow-artifact.ts"; import { resolveManagedSkillMetadataFilePath } from "./skills/managed-skill-paths.ts"; import { createBundledSkillMetadata, @@ -186,10 +185,6 @@ describe("oo uninstall", () => { try { await seedRuntime(sandbox); const store = storePaths(sandbox); - const openFlowCommandCacheRoot = resolveOpenFlowCommandCacheRoot({ - env: sandbox.env, - platform: process.platform, - }); await mkdir(store.rootDirectory, { recursive: true }); await writeFile(store.authFilePath, "id = \"\"\n"); @@ -197,8 +192,6 @@ describe("oo uninstall", () => { // A residual file that no explicit child item targets: it must still // be gone because --purge removes the whole config root. await writeFile(join(store.rootDirectory, "leftover.txt"), "x"); - await mkdir(openFlowCommandCacheRoot, { recursive: true }); - await writeFile(join(openFlowCommandCacheRoot, "entry.js"), "cached"); const registrySkill = await seedHostSkill({ sandbox, skillName: "demo", @@ -223,7 +216,6 @@ describe("oo uninstall", () => { // every platform. expect(await Bun.file(store.authFilePath).exists()).toBe(false); expect(await Bun.file(store.settingsFilePath).exists()).toBe(false); - expect(await pathExists(openFlowCommandCacheRoot)).toBe(false); // All registry skills removed under purge expect(await Bun.file(join(registrySkill, "SKILL.md")).exists()).toBe(false); // Local still retained even under purge diff --git a/src/application/self-update/uninstall.test.ts b/src/application/self-update/uninstall.test.ts index c9ed8b60..65fd9149 100644 --- a/src/application/self-update/uninstall.test.ts +++ b/src/application/self-update/uninstall.test.ts @@ -200,13 +200,6 @@ describe("buildSelfUninstallPlan", () => { expect(userData.length).toBeGreaterThanOrEqual(4); expect(paths(userData).some(path => path.endsWith("auth.toml"))).toBe(true); expect(paths(userData).some(path => path.endsWith("settings.toml"))).toBe(true); - expect(paths(userData)).toContain(join( - tempHome, - ".cache", - "oo", - "open-flow", - "command-artifact-v2", - )); // The config root itself is removed, and it must be the last user-data // item so it sweeps anything the explicit child items did not cover. diff --git a/src/application/self-update/uninstall.ts b/src/application/self-update/uninstall.ts index 48ddde22..bf3c2d3e 100644 --- a/src/application/self-update/uninstall.ts +++ b/src/application/self-update/uninstall.ts @@ -6,7 +6,6 @@ import type { SelfUpdatePaths } from "./paths.ts"; import { mkdir, rm } from "node:fs/promises"; import { dirname, join } from "node:path"; import { resolveStorePaths } from "../../adapters/store/store-path.ts"; -import { resolveOpenFlowCommandCacheRoot } from "../commands/flow-artifact.ts"; import { canonicalBundledSkillsDirectoryName, managedSkillsDirectoryName, @@ -152,11 +151,6 @@ export async function buildSelfUninstallPlan( deferred, immediate, isWindows, - openFlowCommandCacheRoot: resolveOpenFlowCommandCacheRoot({ - env: options.env, - homeDirectory: options.homeDirectory, - platform: options.platform, - }), storePaths, }); } @@ -311,7 +305,6 @@ function addUserDataItems(args: { deferred: UninstallPlanItem[]; immediate: UninstallPlanItem[]; isWindows: boolean; - openFlowCommandCacheRoot: string; storePaths: ReturnType; }): void { args.immediate.push( @@ -339,11 +332,6 @@ function addUserDataItems(args: { }); args.immediate.push( - { - category: "user-data", - label: "Open Flow command cache", - path: args.openFlowCommandCacheRoot, - }, { category: "user-data", label: "Telemetry", diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index b7b884a6..03a3cae8 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -153,10 +153,6 @@ export const enMessages = { "Run the Open Flow CLI with all following arguments passed through unchanged.", "commands.flow.summary": "Run Open Flow", "arguments.flowArgs": "Arguments passed to Open Flow", - "flow.download.complete": "Downloaded and verified Open Flow {version}.", - "flow.download.start": "Downloading Open Flow {version}...", - "errors.flow.commandArtifactUnavailable": - "Open Flow {version} could not be downloaded or verified. Check the network connection and local cache, then try again.", "errors.flow.commandEntryInvalid": "The Open Flow command entry at {path} is invalid.", "errors.flow.commandEntryLoadFailed": @@ -1565,10 +1561,6 @@ export const zhMessages = { "commands.flow.description": "运行 Open Flow CLI,并将后续参数原样传递给它。", "commands.flow.summary": "运行 Open Flow", "arguments.flowArgs": "传递给 Open Flow 的参数", - "flow.download.complete": "已下载并验证 Open Flow {version}。", - "flow.download.start": "正在下载 Open Flow {version}…", - "errors.flow.commandArtifactUnavailable": - "无法下载或验证 Open Flow {version}。请检查网络连接和本地缓存后重试。", "errors.flow.commandEntryInvalid": "{path} 中的 Open Flow 命令入口无效。", "errors.flow.commandEntryLoadFailed": "无法加载 {path} 中的 Open Flow 命令入口:{message}",