diff --git a/contrib/skills/shared/oo/SKILL.md b/contrib/skills/shared/oo/SKILL.md index 11a171b3..df2032eb 100644 --- a/contrib/skills/shared/oo/SKILL.md +++ b/contrib/skills/shared/oo/SKILL.md @@ -1,6 +1,6 @@ --- name: oo -description: Use OO for connected accounts, APIs, and hosted AI tasks; the first-choice router for tasks whose outcome lives outside this workspace, including connected third-party accounts (email, calendar, drive, chat, notes, issue tracker, code host, CRM, storage, etc.), an external API, or a managed AI pipeline (OCR, translation, transcription, TTS, text-to-image, subtitles, long-document understanding). Use when local code needs OOMOL LLM client configuration such as an OpenAI-compatible base URL, API key, or model name. Otherwise use only when the user wants an existing hosted capability or connector workflow, not a local implementation. Concrete capabilities are discovered at runtime, so no package, block, connector, or action names are assumed in advance. Match intent across languages. Skip other pure local coding, shell glue, repo edits, and text-only answers an LLM can complete without hosted capability execution. +description: Use OO for connected accounts, APIs, hosted AI, and Flow authoring; the first-choice router for tasks whose outcome lives outside this workspace or in a persistent Open Flow, including connected third-party accounts (email, calendar, drive, chat, notes, issue tracker, code host, CRM, storage, etc.), an external API, a managed AI pipeline (OCR, translation, transcription, TTS, text-to-image, subtitles, long-document understanding), or creating, editing, checking, running, publishing, and opening an Open Flow workflow. Use when local code needs OOMOL LLM client configuration such as an OpenAI-compatible base URL, API key, or model name. Otherwise use only when the user wants an existing hosted capability, connector workflow, or Open Flow operation, not a local implementation. Concrete capabilities are discovered at runtime, so no package, block, connector, action, or Trigger key names are assumed in advance. Match intent across languages. Skip other pure local coding, shell glue, repo edits, and text-only answers an LLM can complete without hosted capability execution. disable-model-invocation: allowed-tools: [Bash(oo *)] @@ -18,6 +18,16 @@ If the user wants to find, compare, or install published OOMOL/oo skills, use Read only the reference file needed for the current state. +## Open Flow mode + +If the user wants to create, inspect, edit, check, run, publish, or open a +persistent Open Flow workflow, read +[references/flow-authoring.md](references/flow-authoring.md) before the first +`oo flow` command. This mode replaces the connector operating state machine +below: use Flow-scoped Connector and Trigger discovery, not `oo search` or +direct Connector execution. Do not run the wrap-up skill recommendation for +services that were only added to a Flow. + ## LLM client config mode If local code you are writing or running needs an OpenAI-compatible base URL, @@ -200,6 +210,8 @@ unsupported input shape, or a blocker-specific fallback. [references/auth-and-billing.md](references/auth-and-billing.md) - OOMOL LLM client configuration for local code: [references/llm-client.md](references/llm-client.md) +- Persistent Open Flow creation, editing, validation, execution, and release: + [references/flow-authoring.md](references/flow-authoring.md) ## Decision sketches @@ -223,3 +235,10 @@ For `read -> transform -> write`, discover only the current external step. Use local reasoning only to filter, group, rank, summarize, dedupe, or shape the next payload. Switch discovery to the destination service only when the write step becomes active. + +### Persistent Open Flow + +User wants a reusable workflow rather than an immediate Connector result. Use +the Open Flow mode, resolve the Project explicitly, discover Node and Trigger +contracts through `oo flow`, author the smallest complete Draft, then stop at +check, run, or publish according to the requested side-effect boundary. diff --git a/contrib/skills/shared/oo/references/flow-authoring.md b/contrib/skills/shared/oo/references/flow-authoring.md new file mode 100644 index 00000000..92314790 --- /dev/null +++ b/contrib/skills/shared/oo/references/flow-authoring.md @@ -0,0 +1,177 @@ +# Open Flow authoring + +Use this mode for persistent Cloud workflows. Use `--json` for every command +whose output feeds another step. Put `oo` global options such as `--lang` and +`--debug` before `flow`. + +## Contents + +- [Authoring sequence](#authoring-sequence) +- [Atomic graph creation](#atomic-graph-creation) +- [Port compatibility](#port-compatibility) +- [Triggers](#triggers) +- [Opening Workbench](#opening-workbench) +- [Verification and failure handling](#verification-and-failure-handling) + +## Authoring sequence + +1. Resolve the Project with `oo flow project list --json`. Prefer explicit + `--project ` on each command. Use `oo flow project use` only when + the user wants to change the saved default. +2. Locate the Flow with `oo flow list --project --json`. Create a + missing Flow with `oo flow create --project --json` only + when the user asked for creation. +3. Before editing an existing Flow, run + `oo flow inspect --project --json`. Treat its Revision, + Node IDs, handles, Triggers, and check result as authoritative. +4. Discover Connector Nodes with + `oo flow connector search --project --json`, then inspect + the selected action with + `oo flow connector show --project --json`. + Never substitute `oo search`, `oo connector schema`, or `oo connector run`. + Search returns ranked matches, not an exhaustive catalog or connection + status. A missing provider or action in one result is not evidence that it + is unavailable or unauthorized. Retry once with a provider-qualified, + action-shaped query, and use `oo flow connector list --json` if its existence + remains uncertain. +5. Build the smallest complete Draft mutation. Prefer one `oo flow apply` for a + new multi-Node graph; use individual Node commands for isolated edits. +6. Add Triggers separately, connect their `payload`, then inspect or check the + resulting Flow. Run or publish only when the user requested that boundary. + +Do not invent action IDs, Trigger keys, connection IDs, input names, output +names, or config fields. Take them from Flow-scoped search/show output. An +omitted Connector connection or `connection: "default"` selects the active +default when one exists; otherwise treat the connection error as an auth +blocker. Keep action discovery separate from authorization verification. Before +reporting that a known service is not connected, run +`oo flow connector connections --project --json`; require +an explicit empty or inactive result, or a connection or authorization error +from the selected action. A search miss alone is never an auth blocker. + +`--set field=value` values are JSON. Use `field=@file` for a JSON file or +`field=-` for stdin; use `--set @file` or `--set -` to merge an object. + +## Atomic graph creation + +`oo flow apply --file [--expected-revision ] +--project --json` accepts a version-1 one-shot request: + +```json +{ + "version": 1, + "nodes": { + "source": { + "kind": "connector", + "action": "", + "connection": "default", + "inputs": {} + }, + "transform": { + "kind": "code", + "name": "Transform", + "code": "@transform.js" + }, + "destination": { + "kind": "connector", + "action": "", + "connection": "default", + "inputs": {} + } + }, + "edges": [ + { "source": "source", "output": "", "target": "transform", "input": "" }, + { "source": "transform", "output": "", "target": "destination", "input": "" } + ] +} +``` + +References inside this request are local labels, not persistent IDs. `apply` +creates the Nodes, Tasks, CodeModules, bindings, and Edges with one Draft CAS. +It checks the accepted Revision but does not run or publish it. The request is +not a Project export or a persistent local source. If the request itself comes +from stdin, Code source in the same request cannot also use `-`; use `@file` or +inline code instead. + +Use `--expected-revision` when the edit was prepared from an earlier `inspect`. +For one isolated change, use `oo flow connector add/set`, `oo flow node +add/set/remove`, `oo flow code edit/set`, or `oo flow connect/disconnect` rather +than wrapping the change in a batch request. + +## Port compatibility + +Before creating each Edge, compare the exact source output and target input +from `connector show`, `trigger show`, or `inspect`. A concrete output schema +must satisfy the input schema and nullability. Never connect known incompatible +types such as an array output to a string input, even if an older Draft check +accepts the Edge. + +An empty schema `{}` is dynamic, not a conversion. Use a dynamic Code Node +between typed ports only when its implementation returns a value accepted by +the destination. For example, convert a message array to text explicitly: + +```js +export function run(input) { + return { result: input.value.map((item) => item.subject).join("\n") }; +} +``` + +Connect the source to the Code Node's `value` input and its `result` output to +the destination. If the required conversion cannot be expressed with a proven +Code Node contract, stop instead of direct-connecting the ports or claiming the +Flow is valid. + +## Triggers + +Discover provider Triggers with +`oo flow trigger search --project --json` and inspect the +exact key with `oo flow trigger show --project --json`. Add +it with `oo flow trigger add ` plus only +discovered `--connection`, `--set`, `--every`, or `--cron` values. Use +`oo flow trigger set` for later changes. + +Triggers are not `apply` Nodes. After adding one, read its returned `triggerId` +and connect ` payload` to the exact input of the first Node with +`oo flow connect`. Configure complex Webhook request and response behavior in +Workbench when the CLI reports that boundary. + +## Opening Workbench + +Use `oo flow open [flow] --project ` when the user wants the +Workbench opened in their system browser. The command also prints the resolved +Console URL. + +When the user explicitly asks to open the Workbench in the Codex App right-side +preview or another agent-hosted in-app browser, run +`oo flow workbench [flow] --project --json`, read its top-level +`url`, and navigate the host's in-app Browser to that URL. Do not use +`oo flow open` for an in-app preview because it targets the system browser. If +the host has no in-app Browser capability, return the URL instead of claiming +that the preview was opened. + +An already-open Workbench subscribes to Project revision notifications and +updates its Draft after successful `oo flow` mutations. Do not reload, reopen, +or navigate it merely to display a CLI change. Verify writes with `oo flow +inspect` or `oo flow check`. Interact with the browser after a write only when +the user explicitly requests it or when diagnosing an observed stale view; +ambient in-app browser state alone is not permission to control the page. + +## Verification and failure handling + +- `oo flow inspect` is the preferred read-after-write summary. `oo flow check` + validates one immutable Revision without executing code or Connectors. +- `oo flow run` can execute external side effects. `oo flow publish` changes + Live state and can enable automatic Trigger execution. Do neither unless the + user explicitly requested that effect. +- On `project.revision-conflict`, inspect the new Draft and recompute the edit. + Do not drop the expected Revision and overwrite concurrent work. +- On `flow.mutation-outcome-unknown`, inspect before retrying because the write + may already have been accepted. +- On `flow.apply-check-failed`, the mutation was accepted. Do not retry apply; + run `oo flow check` against the current Flow instead. +- On `flow.invalid` after `apply`, the mutation was accepted but the Draft has + diagnostics. Do not report success or retry the same request. Inspect the + current Revision and repair the reported inputs or Edges. +- Stop on missing Connector or Trigger authorization and report the direct + connection or re-authorization action. Do not replace a Flow Node with a + direct third-party API call. diff --git a/docs/commands.md b/docs/commands.md index 4799dba0..b90b97ac 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -23,6 +23,19 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). `XDG_CONFIG_HOME`. - `OO_DATA_DIR`: Override the data directory that holds the local cache, uploads, and download-session state. Defaults to `/data`. +- `OO_OPEN_FLOW_COMMAND_DIR`: During local Open Flow integration testing, point + `oo flow` at an expanded Open Flow command artifact directory. The directory + must contain `entry.js`; the standard repository build writes it to + `packages/open-flow/dist/command/open-flow-command`. When this variable is + unset, `oo flow` uses the Open Flow release bundled with the current `oo` + release. +- `OO_FLOW_PROJECT`: Select an Open Flow Cloud Project for the current + invocation. It takes precedence over the account-and-Team-scoped Project + saved by `oo flow project use` and is not persisted. +- `OO_FLOW_ACCOUNT`: Select a saved account for the current `oo flow` + invocation without changing the active account in `auth.toml`. Use the exact + account ID or `endpoint/name`; an ambiguous `endpoint/name` must be replaced + with the account ID. `OO_API_KEY` still takes precedence when both are set. - `OO_LOG_DIR`: Override the debug-log directory. Takes precedence over every platform default. - `OO_API_KEY`: Run execution commands with this API key without an interactive @@ -80,6 +93,117 @@ use. Truthy values are `1`, `true`, `yes`, or `on` (case-insensitive). - `OO_NO_SELF_UPDATE`: A truthy value disables `oo update`, `oo install`, and `oo check-update` and forces self-update PATH modification off. +## Open Flow + +### `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. + +- Every argument after `flow` is passed to Open Flow unchanged. The main `oo` + CLI does not parse, reorder, or log these arguments. +- 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. +- Root help and generated shell completions list `flow` only when + `OO_ENDPOINT=oomol.dev`. Other endpoints hide it without disabling direct + `oo flow` or `oo help flow` invocations. +- Main `oo` global options such as `--lang` and `--debug` must appear before + `flow`. Options after `flow` belong to Open Flow. +- Open Flow uses the current process's working directory, standard streams, + environment, and signals. Its exit code becomes the `oo` exit code. +- With no delegated arguments, Open Flow shows a Project → Flow → action menu + only when both stdin and stdout are TTYs. Non-interactive invocations print + help and never wait for input. The menu can select or create Projects and + Flows, then call the same stateless view, rename, check, Draft Run, publish, + and Workbench handlers used by non-interactive commands. +- The downloaded archive is accepted only when its length, SHA-256 digest, + internal manifest, complete file set, and Bun version match the release + pinned by `oo`. +- Command Artifact v2 contains only the Cloud 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. +- Open Flow Cloud commands use the current `oo` credential and effective Team. + The Cloud gateway is derived from the current endpoint as + `https://open-flow.`; for example, `OO_ENDPOINT=oomol.dev` uses + `https://open-flow.oomol.dev`. +- `OO_FLOW_ACCOUNT=` selects a saved account only for + the current Flow invocation, including that account's endpoint, Team, and + saved Project context. It does not mutate the globally active account. +- The `oo` credential and Team identity are attached only to `/v1/` requests + for that gateway. Identity headers supplied by the command artifact are + removed before the host writes the current credential and Team selector. + Connector and Trigger operations remain Cloud API operations; the artifact + does not connect to their backing services directly. +- `oo flow project use ` verifies the Project through Cloud and saves + its ID for the current account and Team. Switching account or Team ignores a + Project saved in another scope. An `OO_API_KEY` identity has no persistent + account scope, so use `OO_FLOW_PROJECT` instead. +- The Cloud-only command surface covers Project and Flow authoring, Nodes, + Edges, CodeModules, Connector Tasks, Triggers, checks, Draft/Live Runs, + Publications, rollback, and Workbench deep links. Use `--json` for versioned + machine output and `--project ` for a per-command Project. +- `oo flow inspect ` reads one immutable Draft Revision and reports its + Nodes, Task/CodeModule details, Edges, Triggers, and authoritative Revision + check together. It does not execute user code or call external services. +- `oo flow apply --file [--expected-revision ]` + accepts a version-1 one-shot JSON authoring request and commits all new Nodes + and Edges with one Draft CAS write. `nodes` is keyed by request-local + references; Code nodes use `code` with inline JavaScript or `@path`, Connector + nodes use `action`, optional `connection` (`default` is accepted), and + optional `inputs`; each Edge contains `source`, `output`, `target`, and + `input`. The request is not a local Project or import format. A successful + apply checks the new Revision but never runs or publishes it. Connector add + and apply choose the action's active default connection when `connection` is + omitted or set to `default`, and JSON output reports the selected connection. + If the write succeeds but that final check is unavailable, output still + reports the accepted Revision and explicitly tells callers not to retry the + apply. +- `oo flow node add code --code ` creates the + Node, Task, CodeModule, and final source atomically and reports all three + opaque IDs. `oo flow check` validates the immutable Revision only; credential + availability and real Connector side effects are checked only by an explicit + `oo flow run`. +- `oo flow open [flow]` opens the official Console Workbench in the system + browser and also prints its URL. `oo flow workbench [flow]` prints the same + URL without launching a browser, for scripts and agent-hosted previews. A + Team must be selected because Console Workbench routes are Team-scoped. The + URL carries a short-lived, one-time browser sign-in code for the effective + CLI account and redirects to the requested Workbench after the exchange. It + must not be shared or stored; the API key itself is never placed in the URL. +- Host telemetry records this delegation only as top-level command `flow`, its + success or failure, and duration. It records no delegated subcommand, flags, + Project/Flow ID, free-form argument, or command output. + +Local repository example: + +```bash +cd /path/to/open-flow +bun run --filter @oomol-lab/open-flow build + +cd /path/to/oo-cli +OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \ + bun run index.ts flow --help +``` + +Test the online dev Cloud with a locally built Open Flow command (requires a +dev login and an effective Team): + +```bash +OO_ENDPOINT=oomol.dev \ +OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \ + bun run index.ts flow project list +``` + ## JSON Output Commands that document `--format=json` and `--json` share the following diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 6667e50e..bab9341d 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -21,6 +21,16 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 子目录)。优先级高于 `XDG_CONFIG_HOME`。 - `OO_DATA_DIR`:覆盖数据目录,其中包含本地缓存、上传和下载会话状态。默认值为 `<配置根目录>/data`。 +- `OO_OPEN_FLOW_COMMAND_DIR`:本地联调 Open Flow 时,让 `oo flow` + 使用指定的已展开命令产物目录。该目录必须包含 `entry.js`;Open Flow + 仓库的标准构建会将它写到 + `packages/open-flow/dist/command/open-flow-command`。未设置时,`oo flow` + 使用当前 `oo` 版本固定的 Open Flow release。 +- `OO_FLOW_PROJECT`:只为当前调用选择 Open Flow Cloud Project。它的优先级 + 高于 `oo flow project use` 按账号和 Team 保存的 Project,且不会写回配置。 +- `OO_FLOW_ACCOUNT`:只为当前 `oo flow` 调用选择一个已保存账号,不修改 + `auth.toml` 中的 active account。值可以是精确账号 ID 或 `endpoint/name`;如果 + 后者匹配多个账号,必须改用账号 ID。与 `OO_API_KEY` 同时设置时仍然后者优先。 - `OO_LOG_DIR`:覆盖 debug 日志目录。优先级高于所有平台默认值。 - `OO_API_KEY`:使用该 API key 执行命令,无需交互式登录。设置后 CLI 会构造一个 内存账号,不读取、不要求、也不写入 `auth.toml`,且优先级高于任何已保存的账号。 @@ -65,6 +75,98 @@ CLI 读取以下环境变量以支持内置和自动化场景。真值为 `1`、 - `OO_NO_SELF_UPDATE`:设为真值会禁用 `oo update`、`oo install` 和 `oo check-update`,并强制关闭 self-update 的 PATH 改写。 +## Open Flow + +### `oo flow [args...]` + +运行当前 `oo` 版本固定的 Open Flow CLI release。首次调用会下载并验证对应的 +不可变命令归档;之后直接离线复用已验证的本地缓存,不会在每次启动时检查更新。 + +- `flow` 后的全部参数都会原样传给 Open Flow;主 `oo` CLI + 不解析、不重排,也不把这些参数写入日志。 +- 当前生效的 `oo --lang` locale 会以 `en` 或 `zh-CN` 传给 Open Flow; + Open Flow 自己拥有并随版本发布对应的命令翻译文案。 +- 因此 `oo flow --help` 和 `oo flow --version` 都属于 Open Flow 命令。 + 如需在不加载 Open Flow 的情况下查看宿主侧命令说明,请使用 `oo help flow`。 +- 只有设置 `OO_ENDPOINT=oomol.dev` 时,根帮助和生成的 shell 补全才会列出 + `flow`。其他 endpoint 只会隐藏该命令,不会禁用直接调用 `oo flow` 或 + `oo help flow`。 +- `--lang`、`--debug` 等 `oo` 全局选项必须放在 `flow` 前面; + `flow` 后的选项归 Open Flow 所有。 +- Open Flow 使用当前进程的工作目录、标准输入输出、环境变量和信号; + 它的退出码会直接成为 `oo` 的退出码。 +- 未传入后续参数时,只有 stdin 和 stdout 都是 TTY,Open Flow 才会显示 + Project → Flow → 操作菜单;非交互调用只打印帮助,不等待输入。菜单可以选择或 + 创建 Project 与 Flow,并调用和非交互命令相同的查看、重命名、检查、Draft Run、 + 发布和 Workbench handler。 +- 只有归档长度、SHA-256、内部 manifest、完整文件集合和 Bun 版本都与 `oo` + 固定的 release 一致时,下载内容才会被接受。 +- Command Artifact v2 只包含 Cloud CLI entry 和 license 文件,不包含 Workbench + assets、Deployment Runtime、skills 或本地 Project 实现。 +- 缓存未命中时,交互式终端会在 stderr 原地刷新字节进度;非交互式输出会分别打印 + 一行开始和完成信息。命中缓存时保持静默。 +- `OO_OPEN_FLOW_COMMAND_DIR` 仅用于本地仓库联调;设置后会跳过下载和缓存解析。 +- Open Flow Cloud 子命令使用当前 `oo` 登录凭证和生效的 Team。Cloud gateway + 由当前 endpoint 派生为 `https://open-flow.`;例如 + `OO_ENDPOINT=oomol.dev` 使用 `https://open-flow.oomol.dev`。 +- `OO_FLOW_ACCOUNT=` 只为本次 Flow 调用选择已保存账号, + 同时使用该账号的 endpoint、Team 和已保存 Project context,不会修改全局 active + account。 +- `oo` 登录凭证和 Team identity 只附加到上述 gateway 的 `/v1/` 请求。宿主会先 + 删除 command artifact 提供的身份 header,再写入当前 credential 和 Team selector。 + Connector 与 Trigger 操作仍通过 Cloud API,artifact 不直连它们的后端服务。 +- `oo flow project use ` 会先通过 Cloud 验证 Project,再按当前账号和 Team + 保存其 ID。切换账号或 Team 后不会复用其他 scope 的 Project。`OO_API_KEY` + 身份没有可持久化的账号 scope,应改用 `OO_FLOW_PROJECT`。 +- Cloud-only 命令覆盖 Project 与 Flow authoring、节点、连线、CodeModule、 + Connector Task、Trigger、检查、Draft/Live Run、Publication、Rollback 和 + Workbench deep link。`--json` 输出带版本的机器格式,`--project ` + 只覆盖当前命令的 Project。 +- `oo flow inspect ` 固定并读取一个不可变 Draft Revision,一次返回 Node、 + Task/CodeModule、Edge、Trigger 和该 Revision 的权威 check;它不会执行用户代码或 + 调用外部服务。 +- `oo flow apply --file [--expected-revision ]` 接受 + version 1 的一次性 JSON authoring request,并用一次 Draft CAS 提交其中的所有新 + Node 和 Edge。`nodes` 使用 request-local reference 作为 key;Code Node 的 `code` + 可以是内联 JavaScript 或 `@path`,Connector Node 使用 `action`、可选的 + `connection`(支持 `default`)和可选 `inputs`;每条 Edge 包含 `source`、 + `output`、`target` 与 `input`。该 request 不是本地 Project 或 import 格式。 + apply 成功后会检查新 Revision,但不会运行或发布。Connector add 与 apply 在 + `connection` 省略或为 `default` 时选择 Action 当前的 active default Connection, + JSON 输出会报告实际选择的 Connection。若写入已成功但最终 check 暂时不可用, + 输出仍会报告已接受的 Revision,并明确提示调用方不要重试 apply。 +- `oo flow node add code --code ` 在一个原子 + change set 中创建 Node、Task、CodeModule 和最终 source,并返回三个 opaque ID。 + `oo flow check` 只校验不可变 Revision;credential 当前是否可用以及 Connector 的 + 真实副作用只会由显式 `oo flow run` 检查。 +- `oo flow open [flow]` 会在系统浏览器中打开当前 endpoint 与 Team 对应的官方 + Console Workbench,并同时输出 URL。`oo flow workbench [flow]` 只输出相同 URL, + 不启动浏览器,适用于脚本和 Agent 内置预览。Console Workbench 路由按 Team + 隔离,因此必须先选择 Team。该 URL 携带当前 CLI 有效账号的短期一次性网页登录 + code;交换完成后会跳转到目标 Workbench。不要分享或持久化这个 URL;API key + 本身不会写入 URL。 +- 宿主 telemetry 只把本次委托记录为顶层命令 `flow`,并记录成功/失败和耗时; + 不记录委托的子命令、flags、Project/Flow ID、自由文本参数或命令输出。 + +本地仓库联调示例: + +```bash +cd /path/to/open-flow +bun run --filter @oomol-lab/open-flow build + +cd /path/to/oo-cli +OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \ + bun run index.ts flow --help +``` + +使用本地构建的 Open Flow 命令测试线上 dev Cloud(需要已登录 dev 账号及已选择 Team): + +```bash +OO_ENDPOINT=oomol.dev \ +OO_OPEN_FLOW_COMMAND_DIR=/path/to/open-flow/packages/open-flow/dist/command/open-flow-command \ + bun run index.ts flow project list +``` + ## JSON 输出 文档中带有 `--format=json` 和 `--json` 的命令遵循以下约定: diff --git a/src/adapters/completion/static-completion-renderer.test.ts b/src/adapters/completion/static-completion-renderer.test.ts index b24d40c1..e038ffec 100644 --- a/src/adapters/completion/static-completion-renderer.test.ts +++ b/src/adapters/completion/static-completion-renderer.test.ts @@ -58,4 +58,14 @@ describe("StaticCompletionRenderer", () => { ); expect(output).toContain("en zh"); }); + + test("shows flow completion only for the online dev endpoint", () => { + const renderer = new StaticCompletionRenderer(createTranslator("en")); + const hiddenOutput = renderer.render("fish", createCliCatalog()); + const devOutput = renderer.render("fish", createCliCatalog("oomol.dev")); + const flowCompletion = `complete -c ${APP_NAME} -n '__fish_use_subcommand' -a 'flow'`; + + expect(hiddenOutput).not.toContain(flowCompletion); + expect(devOutput).toContain(flowCompletion); + }); }); diff --git a/src/application/auth/identity.test.ts b/src/application/auth/identity.test.ts index 55e87ad9..f0e89abe 100644 --- a/src/application/auth/identity.test.ts +++ b/src/application/auth/identity.test.ts @@ -201,6 +201,67 @@ describe("requireIdentity", () => { }); }); + test.each([ + { selector: "user-2", expectedId: "user-2" }, + { selector: "oomol.dev/Alice", expectedId: "user-2" }, + ])("selects a saved account for one invocation with $selector", async ({ selector, expectedId }) => { + const devAccount = { + apiKey: "dev-key", + endpoint: "oomol.dev", + id: "user-2", + name: "Alice", + }; + const { context } = createIdentityContext({ + authFile: { auth: [activeAccount, devAccount], id: activeAccount.id }, + }); + + const identity = await requireIdentity(context, selector); + + expect(identity.account.id).toBe(expectedId); + expect(identity.account.endpoint).toBe("oomol.dev"); + expect(identity.source).toBe("file"); + }); + + test("fails when a per-invocation account selector is not found", async () => { + const { context } = createIdentityContext({ + authFile: { auth: [activeAccount], id: activeAccount.id }, + }); + + await expect(requireIdentity(context, "oomol.dev/Alice")).rejects.toMatchObject({ + exitCode: 1, + key: "errors.auth.accountSelectorNotFound", + }); + }); + + test("requires an account ID when endpoint/name is ambiguous", async () => { + const { context } = createIdentityContext({ + authFile: { + auth: [ + { ...activeAccount, endpoint: "oomol.dev" }, + { ...activeAccount, apiKey: "second-key", endpoint: "oomol.dev", id: "user-2" }, + ], + id: activeAccount.id, + }, + }); + + await expect(requireIdentity(context, "oomol.dev/Alice")).rejects.toMatchObject({ + exitCode: 1, + key: "errors.auth.accountSelectorAmbiguous", + }); + }); + + test("keeps OO_API_KEY above a per-invocation saved-account selector", async () => { + const { context } = createIdentityContext({ + authStore: createThrowingAuthStore(), + env: { OO_API_KEY: "env-key" }, + }); + + const identity = await requireIdentity(context, "missing-account"); + + expect(identity.source).toBe("env"); + expect(identity.account.id).toBe("oo-env-override"); + }); + test("uses the shared auth-required key when no account is active", async () => { const { context } = createIdentityContext(); diff --git a/src/application/auth/identity.ts b/src/application/auth/identity.ts index ce9aae68..3bc971b8 100644 --- a/src/application/auth/identity.ts +++ b/src/application/auth/identity.ts @@ -3,7 +3,7 @@ import type { CliExecutionContext, CliTelemetryPropertyValue, } from "../contracts/cli.ts"; -import type { AuthAccount } from "../schemas/auth.ts"; +import type { AuthAccount, AuthFile } from "../schemas/auth.ts"; import { writeAuthBlock } from "../commands/auth/shared.ts"; import { writeLine } from "../commands/shared/output.ts"; @@ -39,6 +39,8 @@ const envOverrideAccountId = "oo-env-override"; const envOverrideAccountName = "Environment (OO_API_KEY)"; const authErrorKeys = { + accountSelectorAmbiguous: "errors.auth.accountSelectorAmbiguous", + accountSelectorNotFound: "errors.auth.accountSelectorNotFound", activeAccountMissing: "auth.account.activeAccountMissing", required: "errors.auth.required", requiredConnectorOnly: "errors.auth.requiredConnectorOnly", @@ -126,6 +128,7 @@ export async function resolveIdentity( */ export async function requireIdentity( context: RequireIdentityContext, + accountSelector?: string, ): Promise { const envIdentity = resolveEnvIdentity(context.env); @@ -134,7 +137,7 @@ export async function requireIdentity( } const authFile = await context.authStore.read(); - const currentAccount = getCurrentAuthAccount(authFile); + const currentAccount = selectAuthAccount(authFile, accountSelector); logResolvedAccount(context, authFile.auth.length, authFile.id, currentAccount); @@ -165,6 +168,34 @@ export async function requireIdentity( ); } +function selectAuthAccount( + authFile: AuthFile, + selector: string | undefined, +): AuthAccount | undefined { + if (selector === undefined) { + return getCurrentAuthAccount(authFile); + } + + const matches = authFile.auth.filter(account => + account.id === selector + || `${account.endpoint}/${account.name}` === selector, + ); + + if (matches.length === 1) { + return matches[0]; + } + + if (matches.length > 1) { + throw new CliUserError(authErrorKeys.accountSelectorAmbiguous, 1, { + selector, + }); + } + + throw new CliUserError(authErrorKeys.accountSelectorNotFound, 1, { + selector, + }); +} + /** * Resolves the endpoint the login flow authenticates against: OO_ENDPOINT or * the public default. The saved account's endpoint is deliberately not diff --git a/src/application/bootstrap/run-cli.ts b/src/application/bootstrap/run-cli.ts index e0cb1706..cab3f109 100644 --- a/src/application/bootstrap/run-cli.ts +++ b/src/application/bootstrap/run-cli.ts @@ -36,6 +36,10 @@ import { import { createTranslator } from "../../i18n/translator.ts"; import { migrateLegacyDefaultTeam } from "../auth/default-team.ts"; import { createCliCatalog } from "../commands/catalog.ts"; +import { + resolveOpenFlowInvocation, + runOpenFlowCommand, +} from "../commands/flow.ts"; import { synchronizeManagedSkillsForAvailableHosts } from "../commands/skills/auto-sync.ts"; import { APP_NAME } from "../config/app-config.ts"; import { @@ -155,8 +159,12 @@ export async function executeCli(invocation: CliInvocation): Promise { const startTimeMs = Date.now(); const sessionId = Bun.randomUUIDv7(); const telemetryRecorder = createTelemetryInvocationRecorder(); - const debugPathEnabled = hasCliDebugFlag(invocation.argv); - const rawCliLanguage = detectCliLanguageFlag(invocation.argv); + const openFlowInvocation = resolveOpenFlowInvocation(invocation.argv); + const ooArgv = openFlowInvocation === undefined + ? invocation.argv + : invocation.argv.slice(0, openFlowInvocation.commandIndex); + const debugPathEnabled = hasCliDebugFlag(ooArgv); + const rawCliLanguage = detectCliLanguageFlag(ooArgv); const parsedCliLanguage = parseExplicitLocale(rawCliLanguage); const storePaths = resolveStorePaths({ appName: APP_NAME, @@ -267,7 +275,7 @@ export async function executeCli(invocation: CliInvocation): Promise { systemLocale: invocation.systemLocale, }), ); - const catalog = createCliCatalog(); + const catalog = createCliCatalog(invocation.env.OO_ENDPOINT?.trim()); const completionRenderer = new StaticCompletionRenderer(translator); const packageName = invocation.packageName ?? packageManifest.name; @@ -326,12 +334,31 @@ export async function executeCli(invocation: CliInvocation): Promise { await synchronizeManagedSkillsForAvailableHosts(context); } - exitCode = await adapter.run({ - argv: invocation.argv, - catalog, - context, - observer: telemetryRecorder.observer, - }); + if (openFlowInvocation !== undefined) { + telemetryRecorder.observer.onCommandResolved?.({ + argCount: 0, + commandPath: ["flow"], + excludeFromTelemetry: false, + flagsCount: 0, + outputFormat: "text", + }); + exitCode = await runOpenFlowCommand(openFlowInvocation.args, context); + + if (exitCode === 0) { + telemetryRecorder.observer.onCommandCompleted?.({ exitCode }); + } + else { + telemetryRecorder.observer.onCommandFailed?.({ exitCode }); + } + } + else { + exitCode = await adapter.run({ + argv: invocation.argv, + catalog, + context, + observer: telemetryRecorder.observer, + }); + } } catch (error) { if (error instanceof CliUserError) { @@ -676,6 +703,17 @@ function getSystemLocale(): string | undefined { } function redactSensitiveCliArguments(argv: readonly string[]): string[] { + const openFlowInvocation = resolveOpenFlowInvocation(argv); + + if (openFlowInvocation !== undefined) { + return [ + ...argv.slice(0, openFlowInvocation.commandIndex + 1), + ...(openFlowInvocation.args.length === 0 + ? [] + : [redactedCliArgumentValue]), + ]; + } + const positionalRule = sensitiveCliPositionalRules.find(rule => rule.commandPath.every((word, index) => argv[index] === word), ); diff --git a/src/application/commands/auth/web.ts b/src/application/commands/auth/web.ts index 3cd1e69d..f29ee81b 100644 --- a/src/application/commands/auth/web.ts +++ b/src/application/commands/auth/web.ts @@ -1,4 +1,5 @@ -import type { CliCommandDefinition } from "../../contracts/cli.ts"; +import type { CliCommandDefinition, CliExecutionContext } from "../../contracts/cli.ts"; +import type { AuthAccount } from "../../schemas/auth.ts"; import { z } from "zod"; import { requireIdentity } from "../../auth/identity.ts"; @@ -17,6 +18,11 @@ const sessionCodeResponseSchema = z.object({ session_code: z.string().min(1), }); +interface BrowserSignIn { + readonly expiresIn: number; + readonly url: string; +} + export const authWebCommand: CliCommandDefinition = { name: "web", summaryKey: "commands.auth.web.summary", @@ -35,46 +41,23 @@ export const authWebCommand: CliCommandDefinition = { }), handler: async (input, context) => { const { account } = await requireIdentity(context); - const redirectTarget = resolveRedirectTarget( - input.redirect, - account.endpoint, - ); - const session = await requestOo({ - authorization: `Bearer ${account.apiKey}`, - context, - errors: { scope: "auth.web" }, - host: { endpoint: account.endpoint, service: "api" }, - label: "Auth web sign-in", - method: "POST", - path: "/v1/auth/session_code", - schema: sessionCodeResponseSchema, - }); - // The session code signs its opener in to the account, so the URL may - // reach stdout only, never a log field. - const signInUrl = buildOoRequestUrl({ - host: { endpoint: account.endpoint, service: "api" }, - path: "/v1/auth/session_code/exchange", - query: { - redirect: redirectTarget, - session_code: session.session_code, - }, - }).toString(); + const signIn = await createBrowserSignIn(account, input.redirect, context); context.telemetry?.recordProperties({ has_custom_redirect: input.redirect !== undefined, }); context.output.emit( - { expiresIn: session.expires_in, url: signInUrl }, + signIn, () => { const colors = createWriterColors(context.stdout); writeLine(context.stdout, context.translator.t("auth.web.open")); - writeLine(context.stdout, colors.hex(loginUrlColor)(signInUrl)); + writeLine(context.stdout, colors.hex(loginUrlColor)(signIn.url)); writeLine( context.stdout, context.translator.t("auth.web.expires", { - seconds: session.expires_in, + seconds: signIn.expiresIn, }), ); writeLine(context.stdout, context.translator.t("auth.web.doNotShare")); @@ -83,6 +66,36 @@ export const authWebCommand: CliCommandDefinition = { }, }; +export async function createBrowserSignIn( + account: Pick, + redirect: string | undefined, + context: Pick, +): Promise { + const redirectTarget = resolveRedirectTarget(redirect, account.endpoint); + const session = await requestOo({ + authorization: `Bearer ${account.apiKey}`, + context, + errors: { scope: "auth.web" }, + host: { endpoint: account.endpoint, service: "api" }, + label: "Auth web sign-in", + method: "POST", + path: "/v1/auth/session_code", + schema: sessionCodeResponseSchema, + }); + // The returned URL signs its opener in to the account. Callers may emit or + // open it, but must never put it in a log field. + const url = buildOoRequestUrl({ + host: { endpoint: account.endpoint, service: "api" }, + path: "/v1/auth/session_code/exchange", + query: { + redirect: redirectTarget, + session_code: session.session_code, + }, + }).toString(); + + return { expiresIn: session.expires_in, url }; +} + // Resolves where the browser lands after the sign-in completes. Without // --redirect the account endpoint's console is used as-is; an explicit target // must parse as an http(s) URL on the endpoint's own domain, so mistakes fail diff --git a/src/application/commands/catalog.ts b/src/application/commands/catalog.ts index 44d8fd81..b27cac56 100644 --- a/src/application/commands/catalog.ts +++ b/src/application/commands/catalog.ts @@ -7,6 +7,7 @@ import { completionCommand } from "./completion.ts"; import { configCommand } from "./config/index.ts"; import { connectorCommand } from "./connector/index.ts"; import { fileCommand } from "./file/index.ts"; +import { flowCommand } from "./flow.ts"; import { infoCommand } from "./info.ts"; import { installCommand } from "./install.ts"; import { llmCommand } from "./llm/index.ts"; @@ -38,7 +39,9 @@ const globalOptions = [ }, ] as const; -export function createCliCatalog(): CliCatalog { +const onlineDevEndpoint = "oomol.dev"; + +export function createCliCatalog(endpoint?: string): CliCatalog { return { name: APP_NAME, descriptionKey: "app.description", @@ -48,6 +51,10 @@ export function createCliCatalog(): CliCatalog { checkUpdateCommand, connectorCommand, fileCommand, + { + ...flowCommand, + hidden: endpoint !== onlineDevEndpoint, + }, infoCommand, installCommand, llmCommand, diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index d142b6a6..678d27e1 100644 --- a/src/application/commands/connector/shared.ts +++ b/src/application/commands/connector/shared.ts @@ -14,6 +14,7 @@ import { isNetworkRestrictedSandboxError, requestOo, } from "../shared/oo-request.ts"; +import { teamIdentityHeaders } from "../team/identity.ts"; export const connectorActionDefinitionSchema = z.object({ description: z.string().optional().default(""), @@ -204,7 +205,7 @@ export async function searchConnectorActions( // The action list itself is identity-independent, but each result's // `authenticated` flag reflects the effective identity's connected // apps, so the identity headers are forwarded like `apps`/`run`. - headers: connectorIdentityHeaders(options.identity), + headers: teamIdentityHeaders(options.identity), host: { baseUrl: options.target.baseUrl }, label: "Connector action search", logFields: { @@ -240,7 +241,7 @@ export async function listConnectorApps( authorization: options.target.authorization, context, errors: { scope: "connectorApps" }, - headers: connectorIdentityHeaders(options.identity), + headers: teamIdentityHeaders(options.identity), host: { baseUrl: options.target.baseUrl }, label: "Connector apps list", path: "/v1/apps", @@ -268,7 +269,7 @@ export async function listConnectorAppsByService( authorization: options.target.authorization, context, errors: { scope: "connectorApps" }, - headers: connectorIdentityHeaders(options.identity), + headers: teamIdentityHeaders(options.identity), host: { baseUrl: options.target.baseUrl }, label: "Connector apps list", logFields: { @@ -366,7 +367,7 @@ export async function runConnectorAction( errors: { scope: "connectorRun" }, headers: { ...connectorConnectionSelectorHeaders(options.connectionSelector), - ...connectorIdentityHeaders(options.identity), + ...teamIdentityHeaders(options.identity), }, host: { baseUrl: options.target.baseUrl }, jsonBody: { input: options.inputData }, @@ -407,7 +408,7 @@ export async function runConnectorProxy( authorization: options.target.authorization, context, errors: { scope: "connectorProxy" }, - headers: connectorIdentityHeaders(options.identity), + headers: teamIdentityHeaders(options.identity), host: { baseUrl: options.target.baseUrl }, jsonBody: options.proxyRequest, label: "Connector proxy", @@ -437,29 +438,6 @@ function connectorActionPath(serviceName: string, actionName: string): string { return `/v1/actions/${encodeURIComponent(serviceName)}.${encodeURIComponent(actionName)}`; } -// Builds the identity request headers (`x-oo-team-name` / `x-oo-team-id`) -// from whichever dimensions the identity carries. Returns an empty object for -// the personal identity so callers can spread it unconditionally. -function connectorIdentityHeaders( - identity: TeamIdentity | undefined, -): Record { - const headers: Record = {}; - - if (identity === undefined) { - return headers; - } - - if (identity.name !== null) { - headers["x-oo-team-name"] = identity.name; - } - - if (identity.id !== null) { - headers["x-oo-team-id"] = identity.id; - } - - return headers; -} - // Self-hosted servers are typically local processes, so a connection failure // usually means the server is not running — the sandbox hint the OOMOL paths // use would send users down the wrong path. diff --git a/src/application/commands/file/download.ts b/src/application/commands/file/download.ts index 893cb9af..18d2ae87 100644 --- a/src/application/commands/file/download.ts +++ b/src/application/commands/file/download.ts @@ -10,6 +10,7 @@ import { getConfiguredFileDownloadOutDir, } from "../../schemas/settings.ts"; import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; +import { createDownloadProgressReporter } from "../shared/download-progress.ts"; import { finalizeDownloadedFile, openTemporaryDownloadFile, @@ -22,7 +23,6 @@ import { parseFileDownloadUrl, } from "./download/input.ts"; import { resolveDownloadPlan } from "./download/plan.ts"; -import { createDownloadProgressReporter } from "./download/progress.ts"; import { createDownloadSessionKey, deleteDownloadSessionBestEffort, diff --git a/src/application/commands/file/download/file-system.test.ts b/src/application/commands/file/download/file-system.test.ts index 4d136725..bd962062 100644 --- a/src/application/commands/file/download/file-system.test.ts +++ b/src/application/commands/file/download/file-system.test.ts @@ -7,6 +7,7 @@ import { createTemporaryDirectory, createTextBuffer, } from "../../../../../__tests__/helpers.ts"; +import { createDownloadProgressReporter } from "../../shared/download-progress.ts"; import { createDownloadSessionRecordFixture, createDownloadSessionStoreSpy, @@ -20,7 +21,6 @@ import { resolveTemporaryDownloadFileName, writeDownloadToTemporaryFile, } from "./file-system.ts"; -import { createDownloadProgressReporter } from "./progress.ts"; describe("resolveTemporaryDownloadFileName", () => { test("skips reserved and existing temporary file names", async () => { diff --git a/src/application/commands/file/download/file-system.ts b/src/application/commands/file/download/file-system.ts index 1cdbbdf7..71f7ea35 100644 --- a/src/application/commands/file/download/file-system.ts +++ b/src/application/commands/file/download/file-system.ts @@ -1,6 +1,6 @@ import type { FileHandle } from "node:fs/promises"; import type { CliExecutionContext } from "../../../contracts/cli.ts"; -import type { DownloadProgressReporter } from "./progress.ts"; +import type { DownloadProgressReporter } from "../../shared/download-progress.ts"; import type { ExistingDownloadSession, WriteDownloadPlan } from "./types.ts"; import { link, lstat, open, rm, unlink } from "node:fs/promises"; diff --git a/src/application/commands/flow-artifact.test.ts b/src/application/commands/flow-artifact.test.ts new file mode 100644 index 00000000..22648f48 --- /dev/null +++ b/src/application/commands/flow-artifact.test.ts @@ -0,0 +1,463 @@ +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("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): { + archive: Uint8Array; + entrySource: string; + release: OpenFlowCommandRelease; +} { + const entrySource = [ + "export const commandArtifactVersion = 2;", + `export const requiredBunVersion = ${JSON.stringify(Bun.version)};`, + "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: Bun.version, + 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), + }; +} + +function createRelease(archive: Uint8Array): 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: Bun.version, + 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 new file mode 100644 index 00000000..11c31621 --- /dev/null +++ b/src/application/commands/flow-artifact.ts @@ -0,0 +1,879 @@ +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 { + if (release.bunVersion !== Bun.version) { + invalid(`Open Flow requires Bun ${release.bunVersion}; received ${Bun.version}.`); + } + + 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 new file mode 100644 index 00000000..c8eb30ce --- /dev/null +++ b/src/application/commands/flow-release.ts @@ -0,0 +1,23 @@ +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: "d66ab2ac95039ed5b187a176efec7937f6874085d692a07273a1c8eecfb25f07", + length: 98_636, + url: "https://static.oomol.com/release/apps/open-flow/command/open-flow-0.0.25-dev-d66ab2ac95039ed5b187a176efec7937f6874085d692a07273a1c8eecfb25f07.tar.gz", + }, + bunVersion: "1.3.14", + format: "open-flow-command-release", + openFlowVersion: "0.0.25-dev", + version: 1, +} as const satisfies OpenFlowCommandRelease; diff --git a/src/application/commands/flow.cli.test.ts b/src/application/commands/flow.cli.test.ts new file mode 100644 index 00000000..c30a0b10 --- /dev/null +++ b/src/application/commands/flow.cli.test.ts @@ -0,0 +1,503 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, test } from "bun:test"; +import { + createCliSandbox, + createTemporaryDirectory, + readLatestLogContent, + toRequest, + writeAuthFile, +} from "../../../__tests__/helpers.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", () => { + expect( + resolveOpenFlowInvocation(["--debug", "--lang=zh", "flow", "dev", "--lang", "en"]), + ).toEqual({ + args: ["dev", "--lang", "en"], + commandIndex: 2, + }); + expect(resolveOpenFlowInvocation(["help", "flow"])).toBeUndefined(); + }); + + test("delegates all flow arguments and returns the Open Flow exit code", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-args-${Bun.randomUUIDv7()}`; + const languageKey = `open-flow-language-${Bun.randomUUIDv7()}`; + + try { + await writeCommandEntry(commandDirectory, [ + `Reflect.set(globalThis, ${JSON.stringify(captureKey)}, [...args]);`, + `Reflect.set(globalThis, ${JSON.stringify(languageKey)}, host.language);`, + "return 7;", + ]); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const result = await sandbox.run([ + "--lang", + "zh", + "flow", + "run", + "project.oo.yaml", + "--connector-token", + "secret-token", + ]); + + expect(result).toEqual({ + exitCode: 7, + stderr: "", + stdout: "", + }); + expect(Reflect.get(globalThis, captureKey)).toEqual([ + "run", + "project.oo.yaml", + "--connector-token", + "secret-token", + ]); + expect(Reflect.get(globalThis, languageKey)).toBe("zh-CN"); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + Reflect.deleteProperty(globalThis, languageKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("connects Cloud requests with trusted identity headers", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + + try { + await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "dev-secret", + endpoint: "oomol.com", + team: "platform", + teamId: "team-1", + }, + ], + }); + await writeCommandEntry(commandDirectory, [ + "const cloudResponse = await host.cloudRequest('/v1/projects?limit=10', {", + " method: 'POST',", + " headers: { authorization: 'artifact-secret', 'content-type': 'application/json', 'x-oo-team-id': 'forged-team', 'x-oo-team-name': 'forged-name', 'x-oomol-token': 'forged-token' },", + " body: JSON.stringify({ name: 'Example' }),", + "});", + "return cloudResponse.status === 202 ? 0 : 9;", + ]); + sandbox.env.OO_ENDPOINT = "oomol.dev"; + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const requests: Request[] = []; + const result = await sandbox.run(["flow", "project", "list"], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + + requests.push(request); + + return new Response(null, { status: 202 }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://open-flow.oomol.dev/v1/projects?limit=10"); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.headers.get("authorization")).toBe("dev-secret"); + expect(requests[0]?.headers.get("x-oo-team-name")).toBe("platform"); + expect(requests[0]?.headers.get("x-oo-team-id")).toBe("team-1"); + expect(requests[0]?.headers.get("x-oomol-token")).toBeNull(); + expect(await requests[0]?.text()).toBe("{\"name\":\"Example\"}"); + } + finally { + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("selects a saved Flow account for one invocation without changing the active account", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + + try { + const authPath = await writeAuthFile(sandbox, { + activeId: "user-prod", + accounts: [ + { + id: "user-prod", + name: "Alice", + apiKey: "prod-secret", + endpoint: "oomol.com", + team: "production", + teamId: "team-prod", + }, + { + id: "user-dev", + name: "Alice", + apiKey: "dev-secret", + endpoint: "oomol.dev", + team: "development", + teamId: "team-dev", + }, + ], + }); + await writeCommandEntry(commandDirectory, [ + "const response = await host.cloudRequest('/v1/projects');", + "return response.status === 204 ? 0 : 9;", + ]); + sandbox.env.OO_FLOW_ACCOUNT = "oomol.dev/Alice"; + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const requests: Request[] = []; + const result = await sandbox.run(["flow", "project", "list"], { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + return new Response(null, { status: 204 }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://open-flow.oomol.dev/v1/projects"); + expect(requests[0]?.headers.get("authorization")).toBe("dev-secret"); + expect(requests[0]?.headers.get("x-oo-team-id")).toBe("team-dev"); + expect(await Bun.file(authPath).text()).toStartWith("id = \"user-prod\""); + } + finally { + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("rejects Cloud control requests outside the configured gateway", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-cloud-error-${Bun.randomUUIDv7()}`; + + try { + await writeAuthFile(sandbox); + await writeCommandEntry(commandDirectory, [ + "try {", + " await host.cloudRequest('https://attacker.example/v1/projects', { method: 'GET' });", + " return 9;", + "} catch (error) {", + ` Reflect.set(globalThis, ${JSON.stringify(captureKey)}, error instanceof Error ? error.message : String(error));`, + " return 0;", + "}", + ]); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + let requestCount = 0; + const result = await sandbox.run(["flow", "project", "list"], { + fetcher: async () => { + requestCount += 1; + return new Response(null, { status: 200 }); + }, + }); + + expect(result.exitCode).toBe(0); + expect(requestCount).toBe(0); + expect(Reflect.get(globalThis, captureKey)).toBe( + "Open Flow Cloud requests must target the configured /v1/ gateway.", + ); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("persists the selected Project for the current account and Team", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-project-context-${Bun.randomUUIDv7()}`; + + try { + const authPath = await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "dev-secret", + endpoint: "oomol.com", + team: "platform", + teamId: "team-1", + }, + ], + }); + await writeCommandEntry(commandDirectory, [ + "const before = await host.getProject();", + "await host.setProject('project-1');", + "const after = await host.getProject();", + `Reflect.set(globalThis, ${JSON.stringify(captureKey)}, { before, after });`, + "return 0;", + ]); + sandbox.env.OO_ENDPOINT = "oomol.dev"; + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const first = await sandbox.run(["flow", "project", "use", "project-1"]); + + expect(first.exitCode).toBe(0); + expect(Reflect.get(globalThis, captureKey)).toEqual({ + after: "project-1", + before: undefined, + }); + + const second = await sandbox.run(["flow", "project", "current"]); + + expect(second.exitCode).toBe(0); + expect(Reflect.get(globalThis, captureKey)).toEqual({ + after: "project-1", + before: "project-1", + }); + + const auth = await Bun.file(authPath).text(); + await Bun.write( + authPath, + auth + .replace("team = \"platform\"", "team = \"other\"") + .replace("team_id = \"team-1\"", "team_id = \"team-2\""), + ); + const changedTeam = await sandbox.run(["flow", "project", "current"]); + + expect(changedTeam.exitCode).toBe(0); + expect(Reflect.get(globalThis, captureKey)).toEqual({ + after: "project-1", + before: undefined, + }); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("hands browser authentication to the official Team-scoped Workbench deep link", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + const captureKey = `open-flow-workbench-url-${Bun.randomUUIDv7()}`; + + try { + await writeAuthFile(sandbox, { + accounts: [ + { + id: "user-1", + name: "Alice", + apiKey: "dev-secret", + endpoint: "oomol.com", + team: "platform/team", + teamId: "team-1", + }, + ], + }); + await writeCommandEntry(commandDirectory, [ + "const url = await host.getWorkbenchUrl('project/1', 'flow/1');", + `Reflect.set(globalThis, ${JSON.stringify(captureKey)}, url);`, + "return 0;", + ]); + sandbox.env.OO_ENDPOINT = "oomol.dev"; + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const requests: Request[] = []; + const result = await sandbox.run(["--debug", "flow", "workbench", "flow/1"], { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + expires_in: 300, + session_code: "workbench-code", + })); + }, + }); + + expect(result.exitCode).toBe(0); + expect(Reflect.get(globalThis, captureKey)).toBe( + "https://api.oomol.dev/v1/auth/session_code/exchange?redirect=https%3A%2F%2Fconsole.oomol.dev%2Fteam%2Fplatform%252Fteam%2Fflows%2Fproject%252F1%2Fflow%252F1%2Fdesign&session_code=workbench-code", + ); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe("https://api.oomol.dev/v1/auth/session_code"); + expect(requests[0]?.headers.get("authorization")).toBe("Bearer dev-secret"); + expect(await readLatestLogContent(sandbox)).not.toContain("workbench-code"); + } + finally { + Reflect.deleteProperty(globalThis, captureKey); + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("keeps every delegated argument out of the debug log", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + + try { + await writeCommandEntry(commandDirectory, ["return 0;"]); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const result = await sandbox.run([ + "flow", + "run", + "private/project.oo.yaml", + "--connector-token", + "secret-token", + ]); + const logContent = await readLatestLogContent(sandbox); + + expect(result.exitCode).toBe(0); + expect(logContent).toContain("\"argv\":[\"flow\",\"\"]"); + expect(logContent).not.toContain("private/project.oo.yaml"); + expect(logContent).not.toContain("secret-token"); + } + finally { + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + 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 () => { + const sandbox = await createCliSandbox(); + + try { + const result = await sandbox.run(["flow", "--help"], { + fetcher: () => Promise.resolve(new Response(null, { status: 404 })), + stderr: { isTTY: true }, + }); + + 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%)`, + ); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects a command artifact built for another Bun version", async () => { + const sandbox = await createCliSandbox(); + const commandDirectory = await createTemporaryDirectory("oo-open-flow-command"); + + try { + await writeCommandEntry(commandDirectory, ["return 0;"], "0.0.0"); + sandbox.env.OO_OPEN_FLOW_COMMAND_DIR = commandDirectory; + + const result = await sandbox.run(["flow", "--version"]); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + `Open Flow requires Bun 0.0.0, but oo is running Bun ${Bun.version}.`, + ); + } + finally { + await Promise.all([ + sandbox.cleanup(), + rm(commandDirectory, { force: true, recursive: true }), + ]); + } + }); + + test("shows flow in root help only for the online dev endpoint", async () => { + const sandbox = await createCliSandbox(); + + try { + const defaultHelp = await sandbox.run(["--help"]); + sandbox.env.OO_ENDPOINT = "oomol.com"; + const productionHelp = await sandbox.run(["--help"]); + sandbox.env.OO_ENDPOINT = " oomol.dev "; + const devHelp = await sandbox.run(["--help"]); + + expect(defaultHelp.exitCode).toBe(0); + expect(defaultHelp.stdout).not.toContain(" flow [args...]"); + expect(productionHelp.exitCode).toBe(0); + expect(productionHelp.stdout).not.toContain(" flow [args...]"); + expect(devHelp.exitCode).toBe(0); + expect(devHelp.stdout).toContain(" flow [args...]"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("provides host-side flow help while the command is hidden", async () => { + const sandbox = await createCliSandbox(); + + try { + const flowHelp = await sandbox.run(["help", "flow"]); + + expect(flowHelp.exitCode).toBe(0); + expect(flowHelp.stdout).toContain("Arguments passed to Open Flow"); + } + finally { + await sandbox.cleanup(); + } + }); +}); + +async function writeCommandEntry( + commandDirectory: string, + body: readonly string[], + requiredBunVersion: string = Bun.version, +): Promise { + await mkdir(commandDirectory, { recursive: true }); + await writeFile( + join(commandDirectory, "entry.js"), + [ + "export const commandArtifactVersion = 2;", + `export const requiredBunVersion = ${JSON.stringify(requiredBunVersion)};`, + "export async function runOpenFlowCommand(args, host) {", + ...body, + "}", + "", + ].join("\n"), + ); +} diff --git a/src/application/commands/flow.ts b/src/application/commands/flow.ts new file mode 100644 index 00000000..4ee2bca2 --- /dev/null +++ b/src/application/commands/flow.ts @@ -0,0 +1,364 @@ +import type { CliCommandDefinition, CliExecutionContext } from "../contracts/cli.ts"; + +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +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 { setAccountFlowProject } from "../schemas/auth.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 { + requireValidTeamIdentity, + resolveTeamIdentity, + teamIdentityHeaders, +} from "./team/identity.ts"; + +const commandDirectoryEnvName = "OO_OPEN_FLOW_COMMAND_DIR"; +const flowAccountEnvName = "OO_FLOW_ACCOUNT"; +const commandArtifactVersion = 2; + +interface CommandModule { + readonly commandArtifactVersion: unknown; + readonly requiredBunVersion: unknown; + readonly runOpenFlowCommand: unknown; +} + +interface OpenFlowInvocation { + readonly args: readonly string[]; + readonly commandIndex: number; +} + +interface OpenFlowCloudSession { + readonly accountId?: string; + readonly apiKey: string; + readonly consoleOrigin: URL; + readonly endpoint: string; + readonly origin: URL; + readonly projectId?: string; + readonly projectTeam: string; + readonly teamName?: string; + readonly teamHeaders: Record; +} + +interface OpenFlowCommandHost { + readonly cloudRequest: (path: string, init?: RequestInit) => Promise; + readonly getWorkbenchUrl: (projectId: string, flowId?: string) => Promise; + readonly getProject: () => Promise; + readonly language: "en" | "zh-CN"; + readonly setProject: (projectId: string) => Promise; +} + +export const flowCommand = { + name: "flow", + summaryKey: "commands.flow.summary", + descriptionKey: "commands.flow.description", + arguments: [ + { + name: "args", + descriptionKey: "arguments.flowArgs", + required: false, + variadic: true, + }, + ], +} satisfies CliCommandDefinition; + +export function resolveOpenFlowInvocation(argv: readonly string[]): OpenFlowInvocation | undefined { + let commandIndex = 0; + + while (commandIndex < argv.length) { + const argument = argv[commandIndex]; + + if (argument === "--debug" || argument?.startsWith("--lang=")) { + commandIndex += 1; + continue; + } + + if (argument === "--lang") { + commandIndex += 2; + continue; + } + + break; + } + + if (argv[commandIndex] !== "flow") { + return undefined; + } + + return { + args: argv.slice(commandIndex + 1), + commandIndex, + }; +} + +export async function runOpenFlowCommand( + args: readonly string[], + context: Pick< + CliExecutionContext, + | "authStore" + | "connectorStore" + | "cwd" + | "env" + | "execPath" + | "fetcher" + | "logger" + | "settingsStore" + | "stderr" + | "translator" + >, +): Promise { + const configuredDirectory = context.env[commandDirectoryEnvName]?.trim(); + let commandDirectory: string; + + if (configuredDirectory) { + commandDirectory = resolve(context.cwd, configuredDirectory); + } + else { + if (openFlowCommandRelease.bunVersion !== Bun.version) { + throw new CliUserError("errors.flow.bunVersionMismatch", 1, { + actual: Bun.version, + required: openFlowCommandRelease.bunVersion, + }); + } + + const progressReporter = createDownloadProgressReporter( + context.stderr, + openFlowCommandRelease.archive.length, + `Open Flow ${openFlowCommandRelease.openFlowVersion}`, + ); + let downloadStarted = false; + let downloadedBytes = 0; + + 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); + } + } + } + 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, + }); + } + } + + const entryPath = join(commandDirectory, "entry.js"); + let loaded: unknown; + + 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, + }); + } + + if (loaded === null || typeof loaded !== "object") { + throw new CliUserError("errors.flow.commandEntryInvalid", 1, { + path: entryPath, + }); + } + + const commandModule = loaded as CommandModule; + + if ( + commandModule.commandArtifactVersion !== commandArtifactVersion + || typeof commandModule.requiredBunVersion !== "string" + || typeof commandModule.runOpenFlowCommand !== "function" + ) { + throw new CliUserError("errors.flow.commandEntryInvalid", 1, { + path: entryPath, + }); + } + + if (commandModule.requiredBunVersion !== Bun.version) { + throw new CliUserError("errors.flow.bunVersionMismatch", 1, { + actual: Bun.version, + required: commandModule.requiredBunVersion, + }); + } + + let cloudSession: Promise | undefined; + let selectedProject: string | undefined; + const host: OpenFlowCommandHost = { + async cloudRequest(path, init = {}) { + const session = await (cloudSession ??= resolveOpenFlowCloudSession(context)); + const url = new URL(path, session.origin); + + if ( + url.origin !== session.origin.origin + || !url.pathname.startsWith("/v1/") + || url.username !== "" + || url.password !== "" + || url.hash !== "" + ) { + throw new TypeError( + "Open Flow Cloud requests must target the configured /v1/ gateway.", + ); + } + + const headers = new Headers(init.headers); + + headers.delete("authorization"); + headers.delete("x-oo-team-id"); + headers.delete("x-oo-team-name"); + headers.delete("x-oomol-token"); + headers.set("authorization", session.apiKey); + + for (const [name, value] of Object.entries(session.teamHeaders)) { + headers.set(name, value); + } + + return await context.fetcher(url, { ...init, headers }); + }, + async getProject() { + const session = await (cloudSession ??= resolveOpenFlowCloudSession(context)); + + return selectedProject ?? session.projectId; + }, + async getWorkbenchUrl(projectId, flowId) { + const session = await (cloudSession ??= resolveOpenFlowCloudSession(context)); + + if (session.teamName === undefined) { + throw new TypeError("Select a Team before opening the Open Flow Workbench."); + } + + const projectPath = `/team/${encodeURIComponent(session.teamName)}/flows/${encodeURIComponent(projectId)}`; + const pathname + = flowId === undefined + ? projectPath + : `${projectPath}/${encodeURIComponent(flowId)}/design`; + const redirect = new URL(pathname, session.consoleOrigin).href; + const signIn = await createBrowserSignIn( + { apiKey: session.apiKey, endpoint: session.endpoint }, + redirect, + context, + ); + + return signIn.url; + }, + language: resolveRequestLanguage(context.translator.locale), + async setProject(projectId) { + const session = await (cloudSession ??= resolveOpenFlowCloudSession(context)); + const accountId = session.accountId; + + if (accountId === undefined) { + throw new TypeError( + "A Project selected under OO_API_KEY cannot be persisted; use OO_FLOW_PROJECT instead.", + ); + } + + await context.authStore.update(auth => + setAccountFlowProject(auth, accountId, { projectId, team: session.projectTeam }), + ); + selectedProject = projectId; + }, + }; + const exitCode = await commandModule.runOpenFlowCommand(args, host); + + if ( + typeof exitCode !== "number" + || !Number.isInteger(exitCode) + || exitCode < 0 + || exitCode > 255 + ) { + throw new CliUserError("errors.flow.commandEntryInvalid", 1, { + path: entryPath, + }); + } + + return exitCode; +} + +async function resolveOpenFlowCloudSession( + context: Pick< + CliExecutionContext, + | "authStore" + | "connectorStore" + | "env" + | "fetcher" + | "logger" + | "settingsStore" + | "translator" + >, +): Promise { + const accountSelector = readTrimmedEnv(context.env, flowAccountEnvName); + const { account, source } = await requireIdentity(context, accountSelector); + const defaultTeam + = accountSelector === undefined + ? await readDefaultTeam(context) + : account.team === undefined + ? undefined + : { id: account.teamId ?? null, name: account.team }; + const identity = requireValidTeamIdentity( + await resolveTeamIdentity( + { + account, + defaultTeam, + resolveAgainstBackend: true, + }, + context, + ), + context, + ); + + let projectTeam = "personal"; + + if (identity !== undefined) { + projectTeam = identity.id === null ? `name:${identity.name}` : `id:${identity.id}`; + } + + return { + ...(source === "file" ? { accountId: account.id } : {}), + apiKey: account.apiKey, + consoleOrigin: new URL(`https://console.${account.endpoint}`), + endpoint: account.endpoint, + origin: new URL(`https://open-flow.${account.endpoint}`), + ...(account.flowProject?.team === projectTeam + ? { projectId: account.flowProject.projectId } + : {}), + projectTeam, + ...(identity?.name === null || identity?.name === undefined + ? {} + : { teamName: identity.name }), + teamHeaders: teamIdentityHeaders(identity), + }; +} diff --git a/src/application/commands/file/download/progress.test.ts b/src/application/commands/shared/download-progress.test.ts similarity index 71% rename from src/application/commands/file/download/progress.test.ts rename to src/application/commands/shared/download-progress.test.ts index 9c768048..079d9f4a 100644 --- a/src/application/commands/file/download/progress.test.ts +++ b/src/application/commands/shared/download-progress.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { createTextBuffer } from "../../../../../__tests__/helpers.ts"; -import { createDownloadProgressReporter, formatByteCount } from "./progress.ts"; +import { createTextBuffer } from "../../../../__tests__/helpers.ts"; +import { createDownloadProgressReporter, formatByteCount } from "./download-progress.ts"; describe("formatByteCount", () => { test("keeps byte-sized values in bytes", () => { @@ -44,4 +44,23 @@ describe("createDownloadProgressReporter", () => { + "\u001B[1A\r\u001B[2KDownloaded 4 B / 4 B (100%)\n", ); }); + + test("identifies a named download", () => { + const stderr = createTextBuffer({ + isTTY: true, + }); + const reporter = createDownloadProgressReporter( + stderr.writer, + 4, + "Open Flow 1.2.3", + ); + + reporter!.render(1); + reporter!.complete(4); + + expect(stderr.read()).toBe( + "Downloading Open Flow 1.2.3: 1 B / 4 B (25%)\n" + + "\u001B[1A\r\u001B[2KDownloaded Open Flow 1.2.3: 4 B / 4 B (100%)\n", + ); + }); }); diff --git a/src/application/commands/file/download/progress.ts b/src/application/commands/shared/download-progress.ts similarity index 76% rename from src/application/commands/file/download/progress.ts rename to src/application/commands/shared/download-progress.ts index 2cf0196e..b3148e56 100644 --- a/src/application/commands/file/download/progress.ts +++ b/src/application/commands/shared/download-progress.ts @@ -1,19 +1,20 @@ -import type { CliExecutionContext } from "../../../contracts/cli.ts"; +import type { CliExecutionContext } from "../../contracts/cli.ts"; import { moveCursorUp, rewriteTerminalLine, -} from "../../../terminal-control.ts"; +} from "../../terminal-control.ts"; export function createDownloadProgressReporter( writer: CliExecutionContext["stderr"], totalBytes: number | undefined, + subject?: string, ): DownloadProgressReporter | undefined { if (writer.isTTY !== true) { return undefined; } - return new DownloadProgressReporter(writer, totalBytes); + return new DownloadProgressReporter(writer, totalBytes, subject); } export class DownloadProgressReporter { @@ -25,6 +26,7 @@ export class DownloadProgressReporter { constructor( private readonly writer: Pick, private readonly totalBytes: number | undefined, + private readonly subject?: string, ) {} render(downloadedBytes: number): void { @@ -44,7 +46,12 @@ export class DownloadProgressReporter { this.lastRenderedBytes = downloadedBytes; this.lastRenderedAt = now; this.writeProgressLine( - formatProgressStatusLine("Downloading", downloadedBytes, this.totalBytes), + formatProgressStatusLine( + "Downloading", + downloadedBytes, + this.totalBytes, + this.subject, + ), ); } @@ -52,7 +59,12 @@ export class DownloadProgressReporter { this.lastRenderedBytes = downloadedBytes; this.lastRenderedAt = Date.now(); this.writeProgressLine( - formatProgressStatusLine("Downloading", downloadedBytes, this.totalBytes), + formatProgressStatusLine( + "Downloading", + downloadedBytes, + this.totalBytes, + this.subject, + ), ); } @@ -60,7 +72,12 @@ export class DownloadProgressReporter { this.lastRenderedBytes = downloadedBytes; this.lastRenderedAt = Date.now(); this.writeProgressLine( - formatProgressStatusLine("Downloaded", downloadedBytes, this.totalBytes), + formatProgressStatusLine( + "Downloaded", + downloadedBytes, + this.totalBytes, + this.subject, + ), ); } @@ -85,9 +102,12 @@ function formatProgressStatusLine( status: "Downloaded" | "Downloading", downloadedBytes: number, totalBytes: number | undefined, + subject: string | undefined, ): string { + const prefix = subject === undefined ? status : `${status} ${subject}:`; + if (totalBytes === undefined) { - return `${status} ${formatByteCount(downloadedBytes)}`; + return `${prefix} ${formatByteCount(downloadedBytes)}`; } const percent = totalBytes === 0 @@ -95,7 +115,7 @@ function formatProgressStatusLine( : Math.max(0, Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))); return [ - status, + prefix, formatByteCount(downloadedBytes), "/", formatByteCount(totalBytes), diff --git a/src/application/commands/skills/bundled-oo-skill-index.test.ts b/src/application/commands/skills/bundled-oo-skill-index.test.ts index 13322a7d..52b0083a 100644 --- a/src/application/commands/skills/bundled-oo-skill-index.test.ts +++ b/src/application/commands/skills/bundled-oo-skill-index.test.ts @@ -26,7 +26,7 @@ async function readBundledOoDescription(): Promise { } describe("bundled oo skill Hermes index prefix", () => { - test("front-loads the connected accounts, APIs, and hosted AI routing signals within the Hermes truncation budget", async () => { + test("front-loads the connected accounts, APIs, hosted AI, and Flow routing signals within the Hermes truncation budget", async () => { const description = await readBundledOoDescription(); const indexPrefix = description .slice(0, hermesSkillIndexPrefixLength) @@ -35,6 +35,7 @@ describe("bundled oo skill Hermes index prefix", () => { expect(indexPrefix).toContain("connected account"); expect(indexPrefix).toContain("api"); expect(indexPrefix).toContain("hosted ai"); + expect(indexPrefix).toContain("flow"); }); test("keeps the full description detailed instead of narrowing it to a single service", async () => { diff --git a/src/application/commands/skills/embedded-assets.test.ts b/src/application/commands/skills/embedded-assets.test.ts index 2226cb59..399cfaee 100644 --- a/src/application/commands/skills/embedded-assets.test.ts +++ b/src/application/commands/skills/embedded-assets.test.ts @@ -25,6 +25,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -34,6 +35,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -43,6 +45,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -52,6 +55,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -61,6 +65,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -70,6 +75,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -79,6 +85,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -88,6 +95,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -97,6 +105,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -106,6 +115,7 @@ describe("embedded skill assets", () => { "agents/openai.yaml", "references/auth-and-billing.md", "references/llm-client.md", + "references/flow-authoring.md", "references/search-and-selection.md", "references/connector-execution.md", "references/file-transfer.md", @@ -425,6 +435,41 @@ describe("embedded skill assets", () => { } }); + test("routes persistent workflows through Flow-scoped authoring", async () => { + for (const agentName of availableBundledSkillAgentNames) { + const skillFiles = getBundledSkillFiles("oo", agentName); + const skillFile = skillFiles.find(file => file.relativePath === "SKILL.md"); + const flowGuide = skillFiles.find( + file => file.relativePath === "references/flow-authoring.md", + ); + + if (skillFile === undefined || flowGuide === undefined) { + throw new Error(`Missing ${agentName} oo Flow authoring guidance`); + } + + const skillContent = normalizeMarkdownWrappingForAssertion( + await readBundledSkillFileContent(skillFile), + ); + const flowContent = normalizeMarkdownWrappingForAssertion( + await readBundledSkillFileContent(flowGuide), + ); + + expect(skillContent).toContain("Open Flow mode"); + expect(skillContent).toContain("references/flow-authoring.md"); + expect(skillContent).toContain("replaces the connector operating state machine"); + expect(skillContent).toContain("services that were only added to a Flow"); + expect(flowContent).toContain("oo flow connector search "); + expect(flowContent).toContain("Prefer one `oo flow apply`"); + expect(flowContent).toContain("Triggers are not `apply` Nodes"); + expect(flowContent).toContain("Do not retry apply"); + expect(flowContent).toContain("Do neither unless the user explicitly requested"); + expect(flowContent).toContain("Never substitute `oo search`"); + expect(flowContent).toContain("array output to a string input"); + expect(flowContent).toContain("An empty schema `{}` is dynamic, not a conversion"); + expect(flowContent).toContain("On `flow.invalid` after `apply`"); + } + }); + test("guides local code toward oo LLM client config", async () => { for (const agentName of availableBundledSkillAgentNames) { const skillFiles = getBundledSkillFiles("oo", agentName); diff --git a/src/application/commands/skills/embedded-assets.ts b/src/application/commands/skills/embedded-assets.ts index f4f944ad..9c13d1a7 100644 --- a/src/application/commands/skills/embedded-assets.ts +++ b/src/application/commands/skills/embedded-assets.ts @@ -19,6 +19,7 @@ import ooOpenAiPolicyPath from "../../../../contrib/skills/shared/oo/agents/open import ooAuthAndBillingReferencePath from "../../../../contrib/skills/shared/oo/references/auth-and-billing.md" with { type: "file" }; import ooConnectorExecutionReferencePath from "../../../../contrib/skills/shared/oo/references/connector-execution.md" with { type: "file" }; import ooFileTransferReferencePath from "../../../../contrib/skills/shared/oo/references/file-transfer.md" with { type: "file" }; +import ooFlowAuthoringReferencePath from "../../../../contrib/skills/shared/oo/references/flow-authoring.md" with { type: "file" }; import ooLlmClientReferencePath from "../../../../contrib/skills/shared/oo/references/llm-client.md" with { type: "file" }; import ooSearchAndSelectionReferencePath from "../../../../contrib/skills/shared/oo/references/search-and-selection.md" with { type: "file" }; import ooSkillPath from "../../../../contrib/skills/shared/oo/SKILL.md" with { type: "file" }; @@ -73,6 +74,7 @@ const bundledSkillRegistry = { authAndBilling: ooAuthAndBillingReferencePath, connectorExecution: ooConnectorExecutionReferencePath, fileTransfer: ooFileTransferReferencePath, + flowAuthoring: ooFlowAuthoringReferencePath, llmClient: ooLlmClientReferencePath, searchAndSelection: ooSearchAndSelectionReferencePath, }), @@ -188,12 +190,14 @@ function createOoReferenceFiles(sourcePaths: { authAndBilling: string; connectorExecution: string; fileTransfer: string; + flowAuthoring: string; llmClient: string; searchAndSelection: string; }): readonly BundledSkillSourceFile[] { return [ createRenderedBundledSkillFile("references/auth-and-billing.md", sourcePaths.authAndBilling), createRenderedBundledSkillFile("references/llm-client.md", sourcePaths.llmClient), + createRenderedBundledSkillFile("references/flow-authoring.md", sourcePaths.flowAuthoring), createRenderedBundledSkillFile("references/search-and-selection.md", sourcePaths.searchAndSelection), createRenderedBundledSkillFile("references/connector-execution.md", sourcePaths.connectorExecution), createRenderedBundledSkillFile("references/file-transfer.md", sourcePaths.fileTransfer), diff --git a/src/application/commands/team/identity.ts b/src/application/commands/team/identity.ts index 56af42ef..58110ef4 100644 --- a/src/application/commands/team/identity.ts +++ b/src/application/commands/team/identity.ts @@ -176,6 +176,27 @@ export function teamNameStatusForTelemetry( return identity?.status ?? "none"; } +// Builds the standard team identity headers for OOMOL service requests. +export function teamIdentityHeaders( + identity: TeamIdentity | undefined, +): Record { + const headers: Record = {}; + + if (identity === undefined) { + return headers; + } + + if (identity.name !== null) { + headers["x-oo-team-name"] = identity.name; + } + + if (identity.id !== null) { + headers["x-oo-team-id"] = identity.id; + } + + return headers; +} + // Renders the identity for humans: the name with its id in parentheses when // both are known, otherwise whichever one is. export function formatTeamIdentityValue( diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index cc731458..c1961bbe 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -245,6 +245,10 @@ const commandTelemetryDecisions = { properties: ["bytes_total_bucket", "rejected_too_large"], reason: "Records upload size bucket and rejection state without path or filename.", }, + "flow": { + kind: "generic", + reason: "Generic command telemetry records only the delegated flow command and its exit code; Open Flow arguments, flags, paths, project, account, and team identities, and tokens are not inspected.", + }, "info": { kind: "generic", reason: "Generic command telemetry is enough; environment paths and agent presence are not recorded.", diff --git a/src/application/commands/uninstall.cli.test.ts b/src/application/commands/uninstall.cli.test.ts index af6bde7d..1ba7d4ac 100644 --- a/src/application/commands/uninstall.cli.test.ts +++ b/src/application/commands/uninstall.cli.test.ts @@ -9,6 +9,7 @@ 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, @@ -185,6 +186,10 @@ 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"); @@ -192,6 +197,8 @@ 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", @@ -216,6 +223,7 @@ 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/schemas/auth.test.ts b/src/application/schemas/auth.test.ts index 8c307507..48a31a03 100644 --- a/src/application/schemas/auth.test.ts +++ b/src/application/schemas/auth.test.ts @@ -7,6 +7,7 @@ import { getNextAuthAccount, renderAuthFile, setAccountDefaultTeam, + setAccountFlowProject, upsertAuthAccount, } from "./auth.ts"; @@ -136,6 +137,40 @@ describe("authTomlFileSchema", () => { }); }); + test("parses a Team-scoped Flow Project", () => { + expect(authTomlFileSchema.parse({ + auth: [ + { + api_key: "secret-1", + endpoint: "oomol.com", + flow_project_id: "project-1", + flow_project_team: "id:team-1", + id: "user-1", + name: "Alice", + }, + ], + id: "user-1", + }).auth[0]?.flowProject).toEqual({ + projectId: "project-1", + team: "id:team-1", + }); + }); + + test("ignores an incomplete Flow Project context", () => { + expect(authTomlFileSchema.parse({ + auth: [ + { + api_key: "secret-1", + endpoint: "oomol.com", + flow_project_id: "project-1", + id: "user-1", + name: "Alice", + }, + ], + id: "user-1", + }).auth[0]?.flowProject).toBeUndefined(); + }); + test("treats a blank default team as unset", () => { expect(authTomlFileSchema.parse({ auth: [ @@ -220,6 +255,21 @@ describe("setAccountDefaultTeam", () => { }); }); +describe("setAccountFlowProject", () => { + test("stores the Project on the named account only", () => { + const next = setAccountFlowProject(createAuthFile(), "user-2", { + projectId: "project-1", + team: "id:team-1", + }); + + expect(next.auth[1]?.flowProject).toEqual({ + projectId: "project-1", + team: "id:team-1", + }); + expect(next.auth[0]?.flowProject).toBeUndefined(); + }); +}); + describe("clearAccountDefaultTeam", () => { test("removes both team fields", () => { const withTeam = setAccountDefaultTeam(createAuthFile(), "user-1", { @@ -244,12 +294,16 @@ describe("clearAccountDefaultTeam", () => { }); describe("upsertAuthAccount", () => { - test("keeps the stored default team when the account logs in again", () => { + test("keeps the stored Team and Flow Project when the account logs in again", () => { const withTeam = setAccountDefaultTeam(createAuthFile(), "user-1", { id: "team-1", name: "acme", }); - const next = upsertAuthAccount(withTeam, { + const withProject = setAccountFlowProject(withTeam, "user-1", { + projectId: "project-1", + team: "id:team-1", + }); + const next = upsertAuthAccount(withProject, { apiKey: "rotated-secret", endpoint: "oomol.com", id: "user-1", @@ -259,6 +313,10 @@ describe("upsertAuthAccount", () => { expect(next.auth[0]).toEqual({ apiKey: "rotated-secret", endpoint: "oomol.com", + flowProject: { + projectId: "project-1", + team: "id:team-1", + }, id: "user-1", name: "Alice", team: "acme", @@ -322,6 +380,37 @@ describe("renderAuthFile", () => { ); }); + test("renders the Team-scoped Flow Project", () => { + expect(renderAuthFile({ + auth: [ + { + apiKey: "secret-1", + endpoint: "oomol.com", + flowProject: { + projectId: "project-1", + team: "id:team-1", + }, + id: "user-1", + name: "Alice", + }, + ], + id: "user-1", + })).toBe( + [ + "id = \"user-1\"", + "", + "[[auth]]", + "id = \"user-1\"", + "name = \"Alice\"", + "api_key = \"secret-1\"", + "endpoint = \"oomol.com\"", + "flow_project_team = \"id:team-1\"", + "flow_project_id = \"project-1\"", + "", + ].join("\n"), + ); + }); + test("omits the team id line when only the name is known", () => { expect(renderAuthFile({ auth: [ diff --git a/src/application/schemas/auth.ts b/src/application/schemas/auth.ts index e7f2803d..c08f01dd 100644 --- a/src/application/schemas/auth.ts +++ b/src/application/schemas/auth.ts @@ -4,6 +4,10 @@ import { z } from "zod"; export const authAccountSchema = z.object({ apiKey: z.string().min(1), endpoint: z.string().min(1), + flowProject: z.object({ + projectId: z.string().min(1), + team: z.string().min(1), + }).strict().optional(), id: z.string().min(1), name: z.string().min(1), // The account's default team identity. `team` is the name every backend @@ -22,6 +26,8 @@ export const authFileSchema = z.object({ // purpose: a hand-edited blank value must degrade to "no default team", not // brick every command that needs a credential. const authAccountTeamTomlShape = { + flow_project_id: z.string().optional(), + flow_project_team: z.string().optional(), team: z.string().optional(), team_id: z.string().optional(), }; @@ -50,6 +56,10 @@ const authAccountTomlSchema = z.union([ endpoint: account.endpoint, id: "id" in account ? account.id : account.ID, name: account.name, + ...optionalFlowProject( + account.flow_project_team, + account.flow_project_id, + ), ...optionalTeamFields(account.team, account.team_id), })); @@ -86,6 +96,13 @@ export function renderAuthFile(authFile: AuthFile): string { if (account.teamId !== undefined) { lines.push(renderTomlLine("team_id", account.teamId)); } + + if (account.flowProject !== undefined) { + lines.push( + renderTomlLine("flow_project_team", account.flowProject.team), + renderTomlLine("flow_project_id", account.flowProject.projectId), + ); + } } return `${lines.join("\n")}\n`; @@ -115,6 +132,10 @@ export function upsertAuthAccount( const existingAccount = authFile.auth[existingIndex]!; const mergedAccount: AuthAccount = { ...account, + ...optionalFlowProject( + account.flowProject?.team ?? existingAccount.flowProject?.team, + account.flowProject?.projectId ?? existingAccount.flowProject?.projectId, + ), ...optionalTeamFields( account.team ?? existingAccount.team, account.teamId ?? existingAccount.teamId, @@ -129,6 +150,17 @@ export function upsertAuthAccount( }; } +export function setAccountFlowProject( + authFile: AuthFile, + accountId: string, + flowProject: NonNullable, +): AuthFile { + return mapAuthAccount(authFile, accountId, account => ({ + ...account, + flowProject, + })); +} + /** * Records the default team identity on one account. The id is stored only * when known, so a default migrated from the legacy global setting (which @@ -262,3 +294,22 @@ function optionalTeamFields( : { teamId: trimmedTeamId }), }; } + +function optionalFlowProject( + team: string | undefined, + projectId: string | undefined, +): { flowProject?: NonNullable } { + const normalizedTeam = team?.trim(); + const normalizedProjectId = projectId?.trim(); + + if ( + normalizedTeam === undefined + || normalizedTeam === "" + || normalizedProjectId === undefined + || normalizedProjectId === "" + ) { + return {}; + } + + return { flowProject: { projectId: normalizedProjectId, team: normalizedTeam } }; +} diff --git a/src/application/self-update/uninstall.test.ts b/src/application/self-update/uninstall.test.ts index 65fd9149..c9ed8b60 100644 --- a/src/application/self-update/uninstall.test.ts +++ b/src/application/self-update/uninstall.test.ts @@ -200,6 +200,13 @@ 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 813fd3ea..fae2ae96 100644 --- a/src/application/self-update/uninstall.ts +++ b/src/application/self-update/uninstall.ts @@ -6,6 +6,7 @@ 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, @@ -147,7 +148,17 @@ export async function buildSelfUninstallPlan( }); if (options.purge) { - addUserDataItems({ deferred, immediate, isWindows, storePaths }); + addUserDataItems({ + deferred, + immediate, + isWindows, + openFlowCommandCacheRoot: resolveOpenFlowCommandCacheRoot({ + env: options.env, + homeDirectory: options.homeDirectory, + platform: options.platform, + }), + storePaths, + }); } return { @@ -300,6 +311,7 @@ function addUserDataItems(args: { deferred: UninstallPlanItem[]; immediate: UninstallPlanItem[]; isWindows: boolean; + openFlowCommandCacheRoot: string; storePaths: ReturnType; }): void { args.immediate.push( @@ -327,6 +339,11 @@ 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 27e8d40e..5cee6503 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -142,6 +142,20 @@ export const enMessages = { "commands.file.upload.description": "Upload a file and store the signed download URL locally.", "commands.file.upload.summary": "Upload a file", + "commands.flow.description": + "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": + "Failed to load the Open Flow command entry at {path}: {message}", + "errors.flow.bunVersionMismatch": + "Open Flow requires Bun {required}, but oo is running Bun {actual}.", "commands.info.description": "Print CLI environment details, persisted store paths, and detected skill agents.", "commands.info.summary": "Show CLI environment info", @@ -312,6 +326,10 @@ export const enMessages = { "Timed out after {timeout} waiting for the device login to complete.", "errors.auth.noSavedAccounts": "There are no auth accounts to switch to.", + "errors.auth.accountSelectorAmbiguous": + "Multiple saved accounts match {selector}. Use an account ID instead.", + "errors.auth.accountSelectorNotFound": + "No saved account matches {selector}. Use an account ID or endpoint/name.", "errors.auth.required": "You must log in before using this command.", "errors.auth.requiredConnectorOnly": @@ -1544,6 +1562,18 @@ export const zhMessages = { "commands.file.summary": "管理临时文件传输", "commands.file.upload.description": "上传文件,并在本地保存带签名的下载地址。", "commands.file.upload.summary": "上传文件", + "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}", + "errors.flow.bunVersionMismatch": + "Open Flow 需要 Bun {required},但 oo 当前运行的是 Bun {actual}。", "commands.info.description": "打印 CLI 运行环境信息、本地存储路径以及检测到的 skill 代理。", "commands.info.summary": "显示 CLI 环境信息", @@ -1699,6 +1729,10 @@ export const zhMessages = { "errors.auth.loginRequestFailed": "auth login 请求返回了 HTTP {status}。", "errors.auth.loginTimeout": "等待 device login 完成超过 {timeout},已超时。", "errors.auth.noSavedAccounts": "没有可切换的认证账号。", + "errors.auth.accountSelectorAmbiguous": + "有多个已保存账号匹配 {selector},请改用账号 ID。", + "errors.auth.accountSelectorNotFound": + "没有已保存账号匹配 {selector},请使用账号 ID 或 endpoint/name。", "errors.auth.required": "使用此命令前请先登录。", "errors.auth.requiredConnectorOnly":