From 3538aa29c08f725e71b9733ed66b1beecf6be39b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 10:29:19 +0000 Subject: [PATCH] feat(org): export-standalone as commercializable openXYOS source pack (P1.2) Default freeos org export-standalone --mode full copies the Apache-2.0 openXYOS tree plus an MIT host-bridge slice and a route/module manifest. --mode slice keeps the earlier org-ui SPA that proxies to FreeOS. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + README.md | 2 +- README_CN.md | 2 +- docs/architecture-integration.md | 4 +- docs/org-capability-migration-map.json | 12 +- docs/org-capability-migration-map.md | 10 +- docs/org-capability-migration-map.zh-CN.md | 10 +- docs/org-export.md | 89 +++-- docs/product-contract.md | 2 +- docs/product-contract.zh-CN.md | 2 +- scripts/org-export/README.md | 57 +-- scripts/org-export/pack/NOTICE | 31 ++ scripts/org-export/pack/README.md | 94 +++++ scripts/org-export/pack/README.zh-CN.md | 94 +++++ scripts/org-export/template/LICENSE | 26 ++ scripts/org-export/template/README.md | 87 +++-- scripts/org-export/template/src/App.tsx | 2 + scripts/org-export/template/src/modules.json | 12 + .../src/shell/CoveragePage.module.css | 65 ++++ .../template/src/shell/CoveragePage.tsx | 116 ++++++ .../template/src/shell/HostDeepLinkPage.tsx | 6 +- scripts/org-export/template/src/shell/Nav.tsx | 1 + scripts/org-export/template/tsconfig.app.json | 1 + src/octop/cli/commands/org.py | 23 +- src/octop/modules/org_os/export_inventory.py | 344 ++++++++++++++++++ src/octop/modules/org_os/export_standalone.py | 287 ++++++++------- tests/unit/cli/test_org_export_standalone.py | 250 +++++++------ 27 files changed, 1282 insertions(+), 348 deletions(-) create mode 100644 scripts/org-export/pack/NOTICE create mode 100644 scripts/org-export/pack/README.md create mode 100644 scripts/org-export/pack/README.zh-CN.md create mode 100644 scripts/org-export/template/LICENSE create mode 100644 scripts/org-export/template/src/modules.json create mode 100644 scripts/org-export/template/src/shell/CoveragePage.module.css create mode 100644 scripts/org-export/template/src/shell/CoveragePage.tsx create mode 100644 src/octop/modules/org_os/export_inventory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f56190..f3d0cab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### 新增 +- P1.2:`freeos org export-standalone` 默认 `--mode full` 写出可商业化 openXYOS **源码包**(`openxyos/` Apache-2.0 完整 App.tsx 树 + `slice/` MIT 宿主桥 + `modules.json` 对照清单)。`--mode slice` 保留原先 org-ui SPA + `FREEOS_UPSTREAM` 反代。工作室智能体对话不导出;组织沟通协作在完整树。说明见 `docs/org-export.md`。 - Organization P1.1(双向资产总线):`freeos org loop run` / `POST /api/org-module/loop/run` / `assets apply` 在托管组织运行时、`OPENXYOS_BASE_URL` 或 `{FREEOS_HOME}/org-os/runtime.json` 可用时,把资产包 ingest 进当前**组织工作区租户**(`remote_applied=true`),不再只写 `openxyos-mirror/`。无运行时地址时仍为镜像-only,且不拉起 Node。回流后同事仍登记为 `org-` 聊天智能体。FreeOS 工作室账号与组织房间身份分开。说明见 `docs/asset-loop.md`。 - P0.2:openXYOS → FreeOS 能力迁移图(`docs/org-capability-migration-map.md` / `.zh-CN.md` + `docs/org-capability-migration-map.json`)。按域记录原生宿主 / org-ui 切片 / 托管 Node iframe / 可选边车 / 仅导出;波次为资产总线 → 导出 → 双身份 UX → 本地模型/知识库 → 拆除 Node。不改运行时。 - Organization Phase 5(默认安装器瘦身):Windows/macOS/Linux 默认包装与 Docker Compose 保持 **单进程 FreeOS + 宿主内 Organization**,不捆绑、不解压、不自动拉起 openXYOS Node。`FREEOS_ORG_SIDECAR=1` 与 `SHIP_OPENXYOS_RUNTIME=1` 仍是可选高级路径。文档对齐 #55 / ADR 001 / ADR 003 / `docs/org-export.md`。`modules/openxyos` 保留给导出与开发,不是默认运行时。 diff --git a/README.md b/README.md index 4aef9351..8d6a72cc 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ The TypeScript sidecar under `modules/openxyos/` is an optional **transition bri | CLI | `uv run freeos org enable` · `uv run freeos org status` · `uv run freeos org loop run` | | Plugin | Admin → Plugins → **Organization OS** (`org-os`) | | API | `PATCH /api/org-module` with `{ "enabled": true }` | -| Standalone commercial site | `uv run freeos org export-standalone --out dist/openxyos-web` — see [docs/org-export.md](docs/org-export.md) | +| Standalone commercial site | `uv run freeos org export-standalone --out dist/openxyos-web` — default `--mode full` writes an openXYOS source tree plus host-bridge slice; see [docs/org-export.md](docs/org-export.md) | Optional Node sidecar (export/sync/advanced deploy only): diff --git a/README_CN.md b/README_CN.md index 0ea19d2b..7da53cbb 100644 --- a/README_CN.md +++ b/README_CN.md @@ -246,7 +246,7 @@ curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install | Linux | `FreeOS-desktop-linux-amd64-.tar.gz` / `FreeOS-desktop-linux-arm64-.tar.gz` | | 飞牛 NAS(FnOS) | `Octop-fnos-docker-.fpk`(依赖 Docker)/ `Octop-fnos-native-.fpk`(无需 Docker)— 通过应用中心安装 | -桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。独立商业组织站见 [docs/org-export.md](docs/org-export.md)(`freeos org export-standalone`),不是默认安装器里的第二套 Node。 +桌面客户端说明见 [desktop/README.md](desktop/README.md),飞牛打包指南见 [fnos/README.md](fnos/README.md)。独立商业组织站见 [docs/org-export.md](docs/org-export.md)(`freeos org export-standalone`,默认导出完整 openXYOS 源码树),不是默认安装器里的第二套 Node。 **备选 — PyPI**(若你已自行管理 Python 环境): diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index 63acf4de..0b154957 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -26,7 +26,7 @@ FreeOS/Octop remains the execution runtime. - [ADR 001](adr/001-single-process-model.md) — single process, no external queue - [ADR 003](adr/003-org-ui-single-source-dual-delivery.md) — org-ui single source, dual delivery (Accepted; read the Current banner) - [org-merge-plan.md](org-merge-plan.md) — Phases 0–5 history; Phase-5 zero-Node is a transition default -- [org-export.md](org-export.md) — standalone commercial Organization site (`freeos org export-standalone`) +- [org-export.md](org-export.md) — standalone commercial openXYOS source pack (`freeos org export-standalone`) - [asset-loop.md](asset-loop.md) — operator self-growth loop - [ADR index](adr/) @@ -305,7 +305,7 @@ The Phase A work sits on the existing FreeOS bootstrap: | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements + org chart CRUD are in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + Open-12 host routes (shared `dashboard/src/org-ui`). Standalone site is **exported** from the same source (`freeos org export-standalone` → Vite SPA + local JWT + proxy to host `/api/org-module`; see [org-export.md](org-export.md)). Sidecar `:3780` iframe is opt-in compat only | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + Open-12 host routes (shared `dashboard/src/org-ui`). Standalone commercial pack is **exported** (`freeos org export-standalone` → default full openXYOS source tree + MIT host-bridge slice; see [org-export.md](org-export.md)). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-capability-migration-map.json b/docs/org-capability-migration-map.json index b21734eb..368e5d72 100644 --- a/docs/org-capability-migration-map.json +++ b/docs/org-capability-migration-map.json @@ -206,24 +206,26 @@ "name_zh": "独立站导出(商业加售)", "lives_today": [ "src/octop/modules/org_os/export_standalone.py", - "src/octop/cli/commands/org.py export-standalone", + "src/octop/cli/commands/org.py export-standalone --mode full|slice", "scripts/org-export/template/", - "dashboard/src/org-ui (copied into dist)", + "scripts/org-export/pack/", + "modules/openxyos (copied into dist/openxyos)", + "dashboard/src/org-ui (copied into dist/slice)", "docs/org-export.md" ], "status": "export_only", "depends_on_node": "partial", "needed_for_commercial_export": "yes", "priority": "P0", - "next_step": "Replace SPA + proxy to FREEOS_UPSTREAM /api/org-module with an exportable openXYOS source tree that a customer can self-host; today's package is an early delivery, not the finished product.", + "next_step": "Unify with POST /api/org-module/source/download; optional self-contained API inside the slice. Default --mode full already copies the openXYOS source tree; do not freeze slice-only SPA+proxy as the commercial artifact.", "wave": "B-export", "evidence": [ - "docs/org-export.md transitional vs target table", + "docs/org-export.md dual-mode full vs slice", "src/octop/modules/org_os/contract.py SHARED_ORG_UI_MODULES" ], "tests": ["tests/unit/cli/test_org_export_standalone.py"], "unknowns": [ - "Self-contained org API inside the export package is still a stated target, not implemented." + "Self-contained org API inside the slice is still a stated follow-on, not implemented." ] }, { diff --git a/docs/org-capability-migration-map.md b/docs/org-capability-migration-map.md index 2d0073bb..6d0e11f6 100644 --- a/docs/org-capability-migration-map.md +++ b/docs/org-capability-migration-map.md @@ -35,7 +35,7 @@ Priority: **P0 = 7** (waves A–D) · **P1 = 17** · **P2 = 10**. | Domain | Status | Node? | Export? | Pri | Next engineering step | |---|---|---|---|---|---| | [Bidirectional assets bus](#1-bidirectional-assets-bus) | native_host | partial | yes | P0 | Host-owned ingest/export that does not need live Node `/api/freeos/ingest`. | -| [Export-standalone](#2-export-standalone-commercial-package) | export_only | partial | yes | P0 | Replace SPA+proxy with exportable openXYOS **source**. | +| [Export-standalone](#2-export-standalone-commercial-package) | export_only | partial | yes | P0 | Default `--mode full` writes the openXYOS **source tree**; `--mode slice` is the SPA+proxy host bridge. | | [Tenant / auth (dual identity)](#3-tenant--auth-dual-identity) | managed_node_iframe | partial | yes | P0 | Two auth spaces; stop Node `/api/auth` as org-room authority. | | [Local models](#4-local-models) | native_host | partial | yes | P0 | Org room uses the studio local-model pool, not sidecar `/api/settings/ai`. | | [Host knowledge bases](#5-host-knowledge-bases) | native_host | no | partial | P0 | Keep local RAG default; cloud connectors optional. | @@ -120,14 +120,14 @@ These shipping shapes still need a native port. They are bridges. | | | |---|---| -| **Lives today** | `export_standalone.py`, `freeos org export-standalone`, `scripts/org-export/template/`, copy of `dashboard/src/org-ui`, [org-export.md](org-export.md) | +| **Lives today** | `export_standalone.py`, `freeos org export-standalone` (`--mode full` default copies `modules/openxyos` + `slice/`), `scripts/org-export/template/` + `pack/`, copy of `dashboard/src/org-ui`, [org-export.md](org-export.md) | | **Status** | `export_only` | -| **Depends on Node?** | partial (dev-time Node to build Vite; runtime proxies to FreeOS) | +| **Depends on Node?** | partial (full tree **runs** with Node; slice still proxies to FreeOS) | | **Commercial export?** | yes | | **Priority** | P0 · Wave B | -| **Next step** | Exportable openXYOS **source tree** a customer can self-host. SPA + `FREEOS_UPSTREAM` proxy is an early delivery, not the finished product. | +| **Next step** | Unify with `POST /api/org-module/source/download`; optional self-contained API inside the slice. Do not freeze slice-only SPA+proxy as the commercial artifact. | | **Tests** | `tests/unit/cli/test_org_export_standalone.py` | -| **Unknown** | Self-contained org API inside the package is a stated target, not implemented. | +| **Unknown** | Slice self-contained org API is still a stated follow-on, not implemented. | ### 3. Tenant / auth (dual identity) diff --git a/docs/org-capability-migration-map.zh-CN.md b/docs/org-capability-migration-map.zh-CN.md index 73916e06..b7ce8cf3 100644 --- a/docs/org-capability-migration-map.zh-CN.md +++ b/docs/org-capability-migration-map.zh-CN.md @@ -35,7 +35,7 @@ | 域 | 状态 | 依赖 Node? | 商业导出? | 优先级 | 下一步(一行) | |---|---|---|---|---|---| | [双向资产总线](#1-双向资产总线) | native_host | 部分 | 是 | P0 | 宿主自有 ingest/export,不依赖活着的 Node `/api/freeos/ingest`。 | -| [独立站导出](#2-独立站导出商业加售) | export_only | 部分 | 是 | P0 | 用可导出的 openXYOS **源码树** 替换 SPA+反代。 | +| [独立站导出](#2-独立站导出商业加售) | export_only | 部分 | 是 | P0 | 默认 `--mode full` 写出 openXYOS **源码树**;`--mode slice` 仍是 SPA+反代宿主桥。 | | [租户与身份(双入口)](#3-租户与身份双入口) | managed_node_iframe | 部分 | 是 | P0 | 两套身份空间;组织房间不再以 Node `/api/auth` 为权威。 | | [本地模型](#4-本地模型) | native_host | 部分 | 是 | P0 | 组织房间使用工作室同一套本机模型池,而不是边车 `/api/settings/ai`。 | | [宿主知识库](#5-宿主知识库) | native_host | 否 | 部分 | P0 | 默认本地 RAG;云连接器可选。 | @@ -120,14 +120,14 @@ | | | |---|---| -| **今日位置** | `export_standalone.py`、`freeos org export-standalone`、`scripts/org-export/template/`、拷贝 `dashboard/src/org-ui`、[org-export.md](org-export.md) | +| **今日位置** | `export_standalone.py`、`freeos org export-standalone`(默认 `--mode full` 拷贝 `modules/openxyos` + `slice/`)、`scripts/org-export/template/` + `pack/`、拷贝 `dashboard/src/org-ui`、[org-export.md](org-export.md) | | **状态** | `export_only` | -| **依赖 Node?** | 部分(构建 Vite 需要 Node;运行时反代到 FreeOS) | +| **依赖 Node?** | 部分(完整树 **运行** 需要 Node;slice 仍反代到 FreeOS) | | **商业导出?** | 是 | | **优先级** | P0 · 波次 B | -| **下一步** | 客户可自托管的 openXYOS **源码树**。SPA + `FREEOS_UPSTREAM` 反代只是早期交付,不是成品。 | +| **下一步** | 与 `POST /api/org-module/source/download` 合一;slice 内自包含 API 仍可选。不要把仅 slice 的 SPA+反代冻成商业交付物。 | | **测试** | `tests/unit/cli/test_org_export_standalone.py` | -| **未知** | 包内自包含 org API 仍是目标,未实现。 | +| **未知** | slice 内自包含 org API 仍是跟进项,未实现。 | ### 3. 租户与身份(双入口) diff --git a/docs/org-export.md b/docs/org-export.md index b43dea72..2e6260cb 100644 --- a/docs/org-export.md +++ b/docs/org-export.md @@ -1,10 +1,10 @@ -# 独立组织站导出(Phase 4 包 + Phase 5 默认安装器) +# 独立组织站导出(可商业化 openXYOS 源码包) -> **Historical vs Current.** **Current:** [产品契约](product-contract.zh-CN.md)。导出可商业化的 openXYOS **系统源码** 是目标;本页描述的 `export-standalone`(SPA + 反代到宿主)是早期交付,不是完成品。Phase-5 默认零 Node 与可选 sidecar/iframe 都是过渡桥。组织像工作室里另一间房间,与日常用法各自留白。本文件不改导出实现(那是后续项)。 +> **Current:** [产品契约](product-contract.zh-CN.md)。`freeos org export-standalone` 默认写出可商业化的 openXYOS **系统源码**(`--mode full`:完整 `App.tsx` 树 + 宿主桥 slice)。`--mode slice` 仍是早期的 org-ui SPA + 反代,给只想对接宿主 `/api/org-module` 的场景。Phase-5 默认零 Node 与可选 sidecar/iframe 仍是过渡桥,不是本导出物。组织像工作室里另一间房间,与日常用法各自留白。 -`freeos org export-standalone --out ` 从 **同一套** `dashboard/src/org-ui` 生成可部署的 openXYOS 风格独立站。这是商业加售的早期交付物,不是第二份手维护前端。 +`freeos org export-standalone --out ` 从 FreeOS 检出生成 **一份可二次产品化的源码包**。默认不是「只有薄壳 + 反代」。 -默认 FreeOS 安装器 **不** 捆绑 Node,也 **不** 自动拉起 openXYOS。Sidecar 仅作可选兼容(`FREEOS_ORG_SIDECAR=1` / `SHIP_OPENXYOS_RUNTIME=1`)。这是 **Phase 5** 已落地的默认拓扑(过渡态):一个 Python 进程 + 宿主内 Organization。 +默认 FreeOS 安装器 **不** 捆绑 Node,也 **不** 自动拉起 openXYOS。Sidecar 仅作可选兼容(`FREEOS_ORG_SIDECAR=1` / `SHIP_OPENXYOS_RUNTIME=1`)。这是 **Phase 5** 已落地的默认拓扑(过渡态):一个 Python 进程 + 宿主内 Organization。导出是显式的商业/运营动作,不改变桌面包。 ## 生成 @@ -12,28 +12,47 @@ ```bash uv run freeos org export-standalone --out dist/openxyos-web +# 等同 --mode full +uv run freeos org export-standalone --out dist/openxyos-web --mode slice ``` -输出目录自带: +### `--mode full`(默认) -- Vite + React 壳,路由对齐 OpenApp(`/app`、`/announcements`、`/org`、`/employees`、`/skills`、`/agents`、`/tasks`、`/knowledge`、`/reflections`、`/governance`、`/settings`) -- 拷贝后的 `src/org-ui`(不含测试) -- 独立 IdentityBridge:本地 JWT,键名 `openxyos.standalone.jwt`(**不是** Dashboard 的 `auth_token`) -- 登录页 → `POST /api/auth/login` -- `Dockerfile` + `docker-compose.yml` + `server/proxy.mjs` +| 路径 | 内容 | 许可证 | +|---|---|---| +| `openxyos/` | `modules/openxyos` 拷贝(完整 `App.tsx` + Express) | Apache-2.0 | +| `slice/` | `dashboard/src/org-ui` + Vite/Docker 宿主桥 | MIT | +| `modules.json` | 路由/模块清单,对照桌面 `/organization` | — | +| `README.md` / `README.zh-CN.md` | 含什么、仍反代什么、怎么跑、商业化说明 | — | + +完整树用 Node 跑原来的组织前端(Open-12、商业垂类、组织沟通协作)。slice 仍把 `/api` 反代到 FreeOS。 -**Chat 不导出。** `/chat` 只是深链说明页。 +### `--mode slice` -模板源在 `scripts/org-export/template/`。改页面请改 `dashboard/src/org-ui`,再重新导出。 +只写出 MIT SPA(与 full 包里的 `slice/` 相同):OpenApp 风格路由、本地 JWT、登录页、Docker、`server/proxy.mjs`。**工作室智能体对话不导出。** 组织沟通协作在完整树里;slice 的 `/chat` 是说明页。`/coverage` 展示与 App.tsx / 宿主清单的对照。 + +模板源在 `scripts/org-export/template/`(slice)与 `scripts/org-export/pack/`(full 包说明)。改宿主切片页面请改 `dashboard/src/org-ui`,再重新导出。 ## 客户怎么跑 -详见导出目录里的 `README.md`。最短路径: +详见导出目录里的 README。最短路径: + +**完整组织应用(产品化):** + +```bash +cd dist/openxyos-web/openxyos +npm ci +npm run dev +``` + +完整树使用 **组织房间自己的注册/登录**,与 FreeOS 工作室日常入口分开。 + +**宿主桥 slice:** ```bash -cd dist/openxyos-web +cd dist/openxyos-web/slice # 或 --mode slice 时的输出根 cp .env.example .env -# 把 FREEOS_UPSTREAM 指到已有 FreeOS(默认 http://127.0.0.1:8088) +# FREEOS_UPSTREAM 指向已有 FreeOS(默认 http://127.0.0.1:8088) npm install npm run dev ``` @@ -44,20 +63,31 @@ npm run dev FREEOS_UPSTREAM=http://127.0.0.1:8088 docker compose up --build ``` -浏览器打开 `http://127.0.0.1:3780`。**Historical / 当前实现:** 登录打 FreeOS `/api/auth/login`。**Current 契约:** 导出站最终应能带上组织房间自己的进入方式;不要把「用工作室日常入口登录独立站」写成终态模型。 +浏览器打开 `http://127.0.0.1:3780`。slice 登录目前打宿主 `/api/auth/login`,只是过渡桥,不是组织房间终态。 + +## 对照桌面组织模块 + +导出根目录(full)或 `src/modules.json`(slice)列出: -## 过渡路径 vs 目标终态 +- 已抽到 `org-ui` 的宿主切片 +- Dashboard `/organization/…` 路由 +- `App.tsx` / `OpenApp.tsx` 每条路由的 `in_slice` / `in_full_tree` / `host_route_exists` +- **有意省略**:工作室智能体对话;桌面 iframe / NSIS;slice 不含商业垂类与组织会话(完整树含) -| | 本波(可演示) | 目标终态 | +slice SPA 的 `/coverage` 用同一份清单做可视化对照。 + +## 仍走宿主反代的 + +| | `--mode slice` / `slice/` | `--mode full` 的 `openxyos/` | |---|---|---| -| UI | 本包 SPA(org-ui) | 同一套 org-ui | -| API | 反向代理到 FreeOS `/api/org-module/*` | 本包自包含服务实现同一合同 | -| 身份 | 本地 JWT,登录打 FreeOS `/api/auth/login` | 客户自己的账号体系发本地 JWT | -| 安装器 | **已落地(Phase 5)**:默认零 Node,sidecar 仅 opt-in | 保持单进程;不把独立站 Node 打进默认安装包 | +| UI | org-ui SPA(Open-12) | 完整 App.tsx | +| API | 反代到 FreeOS `/api/org-module/*` | 自包含 Express | +| 身份 | 本地 JWT;登录打宿主(过渡桥) | 组织房间自己的 `/api/auth` | +| 安装器 | 不打进默认包 | 不打进默认包 | -`VITE_API_BASE` 默认 `/api`(同源)。不要在浏览器里直连另一个源的 FreeOS,除非那个宿主已配好 CORS。推荐始终走代理。 +`VITE_API_BASE` 默认 `/api`(同源)。不要在浏览器里直连另一个源的 FreeOS,除非那个宿主已配好 CORS。slice 推荐始终走代理。 -## 配置 +## 配置(slice) | 变量 | 时机 | 含义 | |---|---|---| @@ -65,9 +95,16 @@ FREEOS_UPSTREAM=http://127.0.0.1:8088 docker compose up --build | `FREEOS_UPSTREAM` | 运行 | 拥有 `/api/org-module` 与 `/api/auth` 的 FreeOS 源 | | `PORT` | `npm start` / Vite | 监听端口,默认 `3780` | +## 许可证 + +- **Apache-2.0**:`openxyos/`(及未改动拷贝),见该目录 `LICENSE` / `NOTICE` +- **MIT**:宿主桥 `slice/`(Vite 壳、IdentityBridge、拷贝的 org-ui、Docker/反代),见 `slice/LICENSE` +- 商标:`openXYOS` / `XYOS` 仅用于源码归属,见 `openxyos/TRADEMARKS.md`(若有) + ## 非目标 / 残留 -- 重写整套 Express → 独立 Node API(导出仍代理到宿主 `/api/org-module`) -- 迁 openXYOS Chat +- 把 slice 也做成自包含 Express(slice 仍反代宿主 `/api/org-module`) +- 导出 FreeOS / Octop **工作室智能体对话**(组织沟通协作在完整树里) - 抽出 `packages/org-ui` monorepo(仅当 Dashboard Vite 图必须脱离时再做) - 把独立站 Node 打进默认 Windows/macOS/Linux 安装器(明确不做) +- 与 `POST /api/org-module/source/download`(GitHub 上游 zip)合成单一产物(仍是跟进项) diff --git a/docs/product-contract.md b/docs/product-contract.md index 052b54fb..0a09cce9 100644 --- a/docs/product-contract.md +++ b/docs/product-contract.md @@ -61,7 +61,7 @@ Prefer models and knowledge bases that run on the operator’s machine. Cloud pr ## Export goal -Operators should be able to customize the organization system and **eventually export a new openXYOS system source** suitable for commercial self-hosting. Today’s `freeos org export-standalone` (SPA + proxy to the host) is an early delivery, not the finished export product. Rewriting that pipeline is **out of scope for this contract freeze** (later work). +Operators should be able to customize the organization system and **export a new openXYOS system source** suitable for commercial self-hosting. `freeos org export-standalone` (default `--mode full`) writes the Apache-2.0 `openxyos/` tree plus an MIT host-bridge `slice`. `--mode slice` is the thinner SPA that still proxies to a FreeOS host. See [org-export.md](org-export.md). ## What not to do next diff --git a/docs/product-contract.zh-CN.md b/docs/product-contract.zh-CN.md index 7277784b..617cc17e 100644 --- a/docs/product-contract.zh-CN.md +++ b/docs/product-contract.zh-CN.md @@ -59,7 +59,7 @@ FreeOS 更像你自己的工作室:先在本机安顿好。组织相关能力 ## 导出目标 -操作者应能定制组织系统,并 **最终导出一套新的 openXYOS 系统源码**,供商业自托管。今天的 `freeos org export-standalone`(SPA + 反代到宿主)只是早期交付,不是完成品。重写导出流水线 **不属于本次契约冻结**(后续工作)。 +操作者应能定制组织系统,并 **导出一套新的 openXYOS 系统源码**,供商业自托管。`freeos org export-standalone`(默认 `--mode full`)写出 Apache-2.0 的 `openxyos/` 树,外加 MIT 宿主桥 `slice`。`--mode slice` 仍是较薄的 SPA,继续反代到 FreeOS 宿主。详见 [org-export.md](org-export.md)。 ## 接下来不要做什么 diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index dd9d1770..25d16e07 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -1,50 +1,57 @@ -# Organization standalone export (Phase 4) +# Organization standalone export -`freeos org export-standalone --out dist/openxyos-web` copies this -`template/` plus `dashboard/src/org-ui` into a **runnable** Vite package. +`freeos org export-standalone --out dist/openxyos-web` writes a +**commercializable openXYOS source pack** (default `--mode full`): -OpenApp-like routes: `/app`, `/announcements`, `/org`, `/employees`, -`/skills`, `/agents`, `/tasks`, `/knowledge`, `/reflections`, -`/governance`, `/settings`. Chat is **not** exported. +1. `openxyos/` — copy of `modules/openxyos` (Apache-2.0, full `App.tsx`) +2. `slice/` — this `template/` plus `dashboard/src/org-ui` (MIT host bridge) +3. `modules.json` — route/module manifest vs desktop `/organization` + +`--mode slice` writes only the MIT SPA at the output root (same files as +`slice/` in a full pack). Chat on the slice is a stub; organization +collaboration chat lives in the full tree. FreeOS/Octop agent chat is +never exported. | Delivery | Route | Component | |---|---|---| | Dashboard | `/organization/workspace` | `WorkspacePage` | -| Standalone | `/app` | same | +| Slice | `/app` | same | | Dashboard | `/organization/announcements` | `AnnouncementPage` | -| Standalone | `/announcements` | same | +| Slice | `/announcements` | same | | Dashboard | `/organization/org` | `OrgChartPage` | -| Standalone | `/org` | same | +| Slice | `/org` | same | | Dashboard | `/organization/employees` | `EmployeesPage` / `EmployeeDetailPage` | -| Standalone | `/employees` | same | +| Slice | `/employees` | same | | Dashboard | `/organization/skills` | `SkillsPage` | -| Standalone | `/skills` | same | +| Slice | `/skills` | same | | Dashboard | `/organization/agents` | `AgentsPage` | -| Standalone | `/agents` | same | +| Slice | `/agents` | same | | Dashboard | `/organization/tasks` | `TasksPage` / `TaskDetailPage` | -| Standalone | `/tasks` | same | +| Slice | `/tasks` | same | | Dashboard | `/organization/knowledge` | `KnowledgePage` | -| Standalone | `/knowledge` | same | +| Slice | `/knowledge` | same | | Dashboard | `/organization/reflections` | `ReflectionsPage` | -| Standalone | `/reflections` | same | +| Slice | `/reflections` | same | | Dashboard | `/organization/governance` | `GovernancePage` | -| Standalone | `/governance` | same | +| Slice | `/governance` | same | | Dashboard | `/organization/settings` | `SettingsPage` | -| Standalone | `/settings` | same | +| Slice | `/settings` | same | +| Slice | `/coverage` | `CoveragePage` (manifest vs App.tsx) | -Identity: standalone `createLocalJwtBridge` (`openxyos.standalone.jwt`), -not the embedded FreeOS Dashboard session. +Identity (slice): `createLocalJwtBridge` (`openxyos.standalone.jwt`), +not the embedded FreeOS Dashboard session. Slice login posting to host +`/api/auth/login` is an interim bridge. -Interim API: SPA + proxy to a FreeOS host (`FREEOS_UPSTREAM`). -Target end-state: self-contained server in the export (see -[docs/org-export.md](../../docs/org-export.md)). +Full tree: self-contained Node+Vite in `openxyos/` (`npm ci && npm run +dev`). Slice still proxies to `FREEOS_UPSTREAM`. See +[docs/org-export.md](../../docs/org-export.md). ## Phase 5 (default installer) Default FreeOS installers stay **zero-Node**. This export is an explicit operator / commercial action — it does not change the desktop package. -Residual (not default install): +Residual: -- Self-contained org API (export still proxies to `FREEOS_UPSTREAM`) -- Optional `packages/org-ui` extraction if the Dashboard Vite graph must be left behind +- Optional self-contained org API **inside the slice** (slice still proxies) +- Unify `POST /api/org-module/source/download` with this generated tree diff --git a/scripts/org-export/pack/NOTICE b/scripts/org-export/pack/NOTICE new file mode 100644 index 00000000..404065e2 --- /dev/null +++ b/scripts/org-export/pack/NOTICE @@ -0,0 +1,31 @@ +openXYOS source pack (FreeOS export) +==================================== + +This tree is generated by ``freeos org export-standalone`` (default ``--mode +full``). It combines two licenses. Keep both notices. + +Apache-2.0 — openxyos/ +---------------------- +The ``openxyos/`` directory is an unmodified (or generated) copy of +``modules/openxyos`` from FreeOS, originally: + + https://github.com/XYAIStudio/openXYOS + +Copyright 2025-2026 Xiongyuan Technology and XYOS contributors +Licensed under the Apache License, Version 2.0. +See ``openxyos/LICENSE`` and ``openxyos/NOTICE``. + +MIT — slice/ (host bridge) +-------------------------- +The ``slice/`` directory (Vite shell, IdentityBridge, copied +``dashboard/src/org-ui`` pages, Docker / proxy helpers) is authored in +FreeOS and licensed under MIT. See ``slice/LICENSE``. + +FreeOS / Octop host platform code that is *not* copied here remains MIT +in the FreeOS repository (root ``LICENSE``). + +Trademarks +---------- +``openXYOS`` / ``XYOS`` names are used for source attribution. Use of +protected marks is subject to ``openxyos/TRADEMARKS.md`` when that file +is present. This pack does not grant trademark rights. diff --git a/scripts/org-export/pack/README.md b/scripts/org-export/pack/README.md new file mode 100644 index 00000000..3cb2f504 --- /dev/null +++ b/scripts/org-export/pack/README.md @@ -0,0 +1,94 @@ +# openXYOS source pack (FreeOS export) + +This directory is produced by `freeos org export-standalone` (default `--mode full`). +It is a **commercializable openXYOS system source** generated from a FreeOS checkout — not only a thin SPA that reverse-proxies the host. + +Chinese: [README.zh-CN.md](README.zh-CN.md) + +## What you get + +| Path | What it is | License | +|---|---|---| +| `openxyos/` | Vendored **full** openXYOS tree (`App.tsx` + Express APIs). Run it with Node. | **Apache-2.0** | +| `slice/` | Host-bridge org-ui SPA (Open-12 pages from `dashboard/src/org-ui`). Proxies `/api` to a FreeOS host. | **MIT** | +| `modules.json` | Route/module manifest vs the desktop host `/organization` inventory | — | +| `NOTICE` | License split and attribution | — | + +`uv run freeos org export-standalone --out … --mode slice` writes only the MIT SPA at the output root (same files as `slice/` here). + +## How to run standalone + +### Full organization app (recommended for productization) + +Needs **Node ≥ 20.19**. This is the original App.tsx surface (Open-12 + commercial verticals + organization collaboration chat). + +```bash +cd openxyos +cp .env.example .env # optional +npm ci +npm run dev # Vite + Express; see openxyos/README.md +``` + +The full tree has its **own organization-room signup/login**. That space is separate from everyday FreeOS studio signup. Do not treat “log in with a FreeOS studio user” as the commercial end state. + +### Host-bridge slice (talks to a running FreeOS) + +Use this when you want the thinner in-host org-ui pages against `/api/org-module` without standing up the Node app. + +```bash +cd slice +cp .env.example .env # set FREEOS_UPSTREAM if FreeOS is not :8088 +npm install +npm run dev # Vite on :3780, proxies /api +``` + +Or: `FREEOS_UPSTREAM=http://127.0.0.1:8088 docker compose up --build` inside `slice/`. + +Slice login currently posts to the host `/api/auth/login` as an **interim bridge**. Organization-room identity in the full tree is separate. Everyday studio use and the organization room stay two ways in. + +## Still host-proxied (slice only) + +| Call | Notes | +|---|---| +| `/api/org-module/*` | Slice CRUD goes to the FreeOS host BFF | +| `/api/auth/login` | Slice bridge only; not the org-room end state | + +The **full** `openxyos/` tree is self-contained: its Express server implements `/api/auth`, `/api/announcements`, `/api/chats`, commercial verticals, and so on. Optional FreeOS ingest (`/api/freeos/*`) remains a bridge if you connect the two products later. + +## Manifest + +`modules.json` lists: + +- `shared_org_ui_modules` — pages extracted into `dashboard/src/org-ui` (also the slice SPA) +- `host_organization_routes` — desktop Dashboard `/organization/…` +- `openxyos_app_routes` — `App.tsx` / `OpenApp.tsx` routes, each flagged `in_slice` / `in_full_tree` / `host_route_exists` +- `omissions` — intentional gaps (see below) + +Compare this file to the host inventory. The slice SPA also renders it at `/coverage`. + +## Intentional omissions + +| Id | Full tree | Slice | Why | +|---|---|---|---| +| `freeos_agent_chat` | no | no | Studio agent chat, IM, cron, sandboxes stay on FreeOS. Not a second agent runtime. | +| `org_collaboration_chat` | **yes** (`/chat`) | stub page | Org rooms ship in `openxyos/`; the slice does not replace studio chat. | +| `desktop_iframe_nsis` | no | no | Managed Node + iframe / NSIS is a FreeOS desktop bridge, not this pack. | + +Commercial `App.tsx` verticals (contracts, assets, attendance, workflows, …) **are** in `openxyos/`. They are omitted from the slice SPA on purpose (documented in `modules.json` as `kind: commercial`, `in_slice: false`). + +## Commercialization notes + +- **Apache-2.0** applies to `openxyos/` (and unmodified copies of that tree). See `openxyos/LICENSE` and `openxyos/NOTICE`. Keep attribution; trademark rules are in `openxyos/TRADEMARKS.md` when present. +- **MIT** applies to the FreeOS-authored host bridge: `slice/` (Vite shell, IdentityBridge, copied `org-ui` pages, Docker/proxy helpers). See `slice/LICENSE`. +- You may productize and self-host the Apache tree. Replacing slice proxy calls with the Express APIs (or your own) is expected follow-on work for a fully detached commercial site. +- This pack is **not** the default FreeOS installer. Default desktop/NSIS/portable builds stay zero-Node. + +## Identity (soft) + +FreeOS is a local studio; organization capabilities are another room you can arrange on its own. Two ways in stay distinct. The full tree’s organization-room accounts are not the studio login, and the slice’s host-bridge login is not the destination identity model. + +## See also + +- `openxyos/README.md` — upstream project intro and `npm run dev` +- `slice/README.md` — slice SPA, Docker, `FREEOS_UPSTREAM` +- FreeOS `docs/org-export.md` and `docs/product-contract.md` diff --git a/scripts/org-export/pack/README.zh-CN.md b/scripts/org-export/pack/README.zh-CN.md new file mode 100644 index 00000000..2ef3a8c2 --- /dev/null +++ b/scripts/org-export/pack/README.zh-CN.md @@ -0,0 +1,94 @@ +# openXYOS 源码包(FreeOS 导出) + +本目录由 `freeos org export-standalone` 生成(默认 `--mode full`)。 +这是从 FreeOS 检出生成的 **可商业化 openXYOS 系统源码**,不只是一层反代到宿主的薄 SPA。 + +English: [README.md](README.md) + +## 包内有什么 + +| 路径 | 内容 | 许可证 | +|---|---|---| +| `openxyos/` | 完整 openXYOS 树(`App.tsx` + Express API)。用 Node 运行。 | **Apache-2.0** | +| `slice/` | 宿主桥 org-ui SPA(来自 `dashboard/src/org-ui` 的 Open-12 页)。`/api` 反代到 FreeOS。 | **MIT** | +| `modules.json` | 路由/模块清单,可对照桌面宿主 `/organization` | — | +| `NOTICE` | 许可证拆分与归属 | — | + +`uv run freeos org export-standalone --out … --mode slice` 只把 MIT SPA 写到输出根目录(等同这里的 `slice/`)。 + +## 如何独立运行 + +### 完整组织应用(适合产品化) + +需要 **Node ≥ 20.19**。这是原来的 App.tsx 表面(Open-12 + 商业垂类 + 组织沟通协作)。 + +```bash +cd openxyos +cp .env.example .env # 可选 +npm ci +npm run dev # Vite + Express;详见 openxyos/README.md +``` + +完整树有 **自己的组织房间注册/登录**。那一套与 FreeOS 工作室日常注册分开。不要把「用工作室日常账号登录」写成商业终态。 + +### 宿主桥 slice(对接正在运行的 FreeOS) + +只想跑较薄的宿主 org-ui 页、走 `/api/org-module`、先不拉起 Node 应用时用这个。 + +```bash +cd slice +cp .env.example .env # FreeOS 不在 :8088 时设置 FREEOS_UPSTREAM +npm install +npm run dev # Vite :3780,反代 /api +``` + +或在 `slice/` 里:`FREEOS_UPSTREAM=http://127.0.0.1:8088 docker compose up --build`。 + +slice 登录目前 `POST` 到宿主 `/api/auth/login`,只是 **过渡桥**。完整树里的组织房间身份是另一套。工作室日常与组织房间两种进入方式保持分开。 + +## 仍走宿主反代的(仅 slice) + +| 调用 | 说明 | +|---|---| +| `/api/org-module/*` | slice 的 CRUD 打到 FreeOS 宿主 BFF | +| `/api/auth/login` | 仅 slice 桥;不是组织房间终态 | + +**完整** `openxyos/` 树是自包含的:Express 实现 `/api/auth`、`/api/announcements`、`/api/chats`、商业垂类等。若以后要接回 FreeOS,可选的 `/api/freeos/*` ingest 仍是桥。 + +## 清单 + +`modules.json` 记录: + +- `shared_org_ui_modules` — 抽到 `dashboard/src/org-ui` 的页面(也是 slice SPA) +- `host_organization_routes` — 桌面 Dashboard `/organization/…` +- `openxyos_app_routes` — `App.tsx` / `OpenApp.tsx` 路由,带 `in_slice` / `in_full_tree` / `host_route_exists` +- `omissions` — 有意不导出的项(见下) + +可与宿主清单对照。slice SPA 在 `/coverage` 也会渲染这份清单。 + +## 有意省略 + +| Id | 完整树 | Slice | 原因 | +|---|---|---|---| +| `freeos_agent_chat` | 无 | 无 | 工作室智能体对话、IM、定时任务、沙箱留在 FreeOS。不是第二套运行时。 | +| `org_collaboration_chat` | **有**(`/chat`) | 说明页 | 组织会话在 `openxyos/`;slice 不替代工作室对话。 | +| `desktop_iframe_nsis` | 无 | 无 | 托管 Node + iframe / NSIS 是 FreeOS 桌面过渡桥,不是本包。 | + +商业 `App.tsx` 垂类(合同、资产、考勤、流程……)**在** `openxyos/` 里。slice SPA 故意不含它们(`modules.json` 里 `kind: commercial`,`in_slice: false`)。 + +## 商业化说明 + +- **Apache-2.0** 适用于 `openxyos/`(及未改动的拷贝)。见 `openxyos/LICENSE`、`openxyos/NOTICE`。保留归属;商标见 `openxyos/TRADEMARKS.md`(若有)。 +- **MIT** 适用于 FreeOS 编写的宿主桥:`slice/`(Vite 壳、IdentityBridge、拷贝的 `org-ui`、Docker/反代)。见 `slice/LICENSE`。 +- 可以对 Apache 树做产品化与自托管。把 slice 反代换成 Express API(或自有实现)是完全脱离宿主之后的跟进工作。 +- 本包 **不是** 默认 FreeOS 安装器。默认桌面 / NSIS / 便携包仍为零 Node。 + +## 身份(柔和表述) + +FreeOS 更像本机工作室;组织能力是另一间可独立布置的房间。两种进入方式保持分开。完整树的组织房间账号不是工作室登录;slice 的宿主桥登录也不是终态身份模型。 + +## 另见 + +- `openxyos/README.md` — 上游介绍与 `npm run dev` +- `slice/README.md` — slice SPA、Docker、`FREEOS_UPSTREAM` +- FreeOS `docs/org-export.md` 与 `docs/product-contract.zh-CN.md` diff --git a/scripts/org-export/template/LICENSE b/scripts/org-export/template/LICENSE new file mode 100644 index 00000000..a2c15d9b --- /dev/null +++ b/scripts/org-export/template/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 FreeOS contributors +Copyright (c) 2026 Octop contributors +Copyright (c) 2026 XYAI Labs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +This host-bridge slice (Vite shell + copied dashboard/src/org-ui pages) is MIT. +When a sibling ../openxyos/ tree is present, that tree is Apache-2.0. diff --git a/scripts/org-export/template/README.md b/scripts/org-export/template/README.md index 717b492c..83bcf057 100644 --- a/scripts/org-export/template/README.md +++ b/scripts/org-export/template/README.md @@ -1,15 +1,19 @@ -# Standalone Organization web (openXYOS) +# Host-bridge Organization slice (openXYOS) -This directory is produced by `freeos org export-standalone`. -Pages come from **the same** `dashboard/src/org-ui` source Dashboard mounts -under `/organization/...`. Do not fork those pages. +This directory is the **MIT host-bridge slice** from `freeos org export-standalone`. -Chat is **not** migrated. `/chat` is a deep-link stub that points operators -back to FreeOS / Octop. +Pages come from **the same** `dashboard/src/org-ui` source the FreeOS Dashboard +mounts under `/organization/...`. Do not fork those pages. + +Default export (`--mode full`) places this tree under `slice/` next to the +Apache-2.0 `openxyos/` app. `--mode slice` writes only this SPA at the output +root. + +中文说明见下方。 ## What you get -| Module | Standalone route | Host API (interim) | +| Module | Standalone route | Host API (proxied) | |---|---|---| | workspace | `/app` | `GET /api/org-module/overview` | | announcements | `/announcements` | `/api/org-module/announcements` | @@ -28,24 +32,36 @@ Components: `AnnouncementPage`, `OrgChartPage`, `EmployeesPage`, `TasksPage`, `TaskDetailPage`, `ReflectionsPage`, `SettingsPage`, `AgentsPage`, `WorkspacePage`. +`/coverage` renders `modules.json` against the desktop org inventory. +Commercial `App.tsx` verticals (contracts, assets, attendance, …) are **not** +in this slice; they ship in the sibling `openxyos/` tree of a full pack. + +## Chat (two different things) + +- **Organization collaboration chat** ships in the **full** pack (`openxyos/` + `App.tsx` `/chat`). This slice only has a stub at `/chat`. +- **FreeOS / Octop agent chat** (studio conversations) is **not** exported. + Open `/chat` on the host Dashboard. This slice does not ship a second agent + runtime. + ## Auth (IdentityBridge) -Standalone uses a **local JWT** stored as `openxyos.standalone.jwt`. +This slice uses a **local JWT** stored as `openxyos.standalone.jwt`. That key is distinct from the FreeOS Dashboard `auth_token` session. -The login page posts to `/api/auth/login` on the configured FreeOS host -(via the same-origin proxy). After sign-in, every org-ui request sends -`Authorization: Bearer `. +The login page currently posts to `/api/auth/login` on the configured FreeOS +host (via the same-origin proxy). That is an **interim bridge**, not the +organization-room end state. The full `openxyos/` tree has its own room +signup/login. Everyday studio use and the organization room stay two ways in. -## Interim vs target API +## Still host-proxied -**Interim (this package):** the SPA talks to `/api` on its own origin. -`npm run dev`, `npm start`, and Docker **proxy** `/api` to a FreeOS host -(`FREEOS_UPSTREAM`). That host already implements `/api/org-module/*`. +**This slice:** the SPA talks to `/api` on its own origin. `npm run dev`, +`npm start`, and Docker **proxy** `/api` to a FreeOS host (`FREEOS_UPSTREAM`) +that already implements `/api/org-module/*`. -**Target end-state:** a self-contained server in this package that stores -org data and issues its own local JWT — no FreeOS process required. -`server/proxy.mjs` is the placeholder for that server. +**Full pack `openxyos/`:** self-contained Express APIs — no FreeOS process +required to run App.tsx. ## Run locally @@ -63,7 +79,7 @@ npm run build FREEOS_UPSTREAM=http://127.0.0.1:8088 npm start ``` -Open `http://127.0.0.1:3780`, sign in with a FreeOS user. +Open `http://127.0.0.1:3780`. Sign-in currently uses a host user as a bridge. ## Docker @@ -83,8 +99,33 @@ On Linux the compose file adds `host-gateway` so `host.docker.internal` works. | `FREEOS_UPSTREAM` | runtime | FreeOS origin that owns `/api/org-module` and `/api/auth`. | | `PORT` | `npm start` / Vite | Listen port (default 3780). | -## Not included +## License + +MIT for this slice (see `LICENSE`). The sibling `openxyos/` tree, when present, +is Apache-2.0. + +--- + +# 宿主桥组织切片(openXYOS) + +本目录是 `freeos org export-standalone` 的 **MIT 宿主桥 slice**。 + +页面与 FreeOS Dashboard 在 `/organization/...` 下挂载的 **同一套** +`dashboard/src/org-ui` 同源,请勿分叉。 + +默认导出(`--mode full`)把它放在 `slice/`,旁边是 Apache-2.0 的 `openxyos/`。 +`--mode slice` 只把本 SPA 写到输出根目录。 + +## 仍走宿主的部分 + +slice 的 `/api` 反代到 `FREEOS_UPSTREAM`(`/api/org-module/*` 与过渡性的 +`/api/auth/login`)。完整 `openxyos/` 树自带 Express,不需要 FreeOS 也能跑 +App.tsx。 + +## 有意省略 + +- **工作室智能体对话**不导出,留在 FreeOS 宿主 `/chat`。 +- **组织沟通协作**在完整包的 `openxyos/` 里;本 slice 的 `/chat` 只是说明页。 +- 商业 App.tsx 垂类不在本 SPA,在完整树里。 -- openXYOS Chat / a second agent runtime -- Default FreeOS installer changes (sidecar stays optional — Phase 5) -- Commercial `App.tsx` routes (contracts, attendance, …) +工作室日常与组织房间两种进入方式保持分开。slice 登录打宿主只是过渡桥,不是终态。 diff --git a/scripts/org-export/template/src/App.tsx b/scripts/org-export/template/src/App.tsx index 5c530809..42040d03 100644 --- a/scripts/org-export/template/src/App.tsx +++ b/scripts/org-export/template/src/App.tsx @@ -24,6 +24,7 @@ import { type OrgTasksClient, } from "org-ui"; import { LoginPage } from "./auth/LoginPage"; +import { CoveragePage } from "./shell/CoveragePage"; import { HostDeepLinkPage } from "./shell/HostDeepLinkPage"; import { StandaloneNav } from "./shell/Nav"; @@ -304,6 +305,7 @@ export default function App() { /> } /> + } /> } diff --git a/scripts/org-export/template/src/modules.json b/scripts/org-export/template/src/modules.json new file mode 100644 index 00000000..3c4926f8 --- /dev/null +++ b/scripts/org-export/template/src/modules.json @@ -0,0 +1,12 @@ +{ + "pack": "openxyos-source", + "mode": "slice", + "licenses": { + "openxyos_tree": "Apache-2.0", + "host_bridge_slice": "MIT" + }, + "shared_org_ui_modules": [], + "host_organization_routes": [], + "openxyos_app_routes": [], + "omissions": [] +} diff --git a/scripts/org-export/template/src/shell/CoveragePage.module.css b/scripts/org-export/template/src/shell/CoveragePage.module.css new file mode 100644 index 00000000..85fcf3f3 --- /dev/null +++ b/scripts/org-export/template/src/shell/CoveragePage.module.css @@ -0,0 +1,65 @@ +.page { + max-width: 960px; + margin: 0 auto; + padding: 32px 16px 64px; +} + +.lead { + color: #555; + line-height: 1.6; +} + +.list { + columns: 2; + margin: 0 0 24px; + padding-left: 20px; +} + +.tableWrap { + overflow-x: auto; + margin-bottom: 24px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th, +.table td { + border: 1px solid #e5e5e5; + padding: 6px 8px; + text-align: left; +} + +.table th { + background: #fafafa; +} + +.omissions { + list-style: none; + padding: 0; + margin: 0 0 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.omissions p { + margin: 6px 0 0; + color: #555; + line-height: 1.6; +} + +.meta { + display: block; + font-size: 12px; + color: #888; + margin-top: 4px; +} + +.foot { + color: #666; + font-size: 13px; +} diff --git a/scripts/org-export/template/src/shell/CoveragePage.tsx b/scripts/org-export/template/src/shell/CoveragePage.tsx new file mode 100644 index 00000000..dddc2968 --- /dev/null +++ b/scripts/org-export/template/src/shell/CoveragePage.tsx @@ -0,0 +1,116 @@ +import { Typography } from "antd"; +import manifest from "../modules.json"; +import styles from "./CoveragePage.module.css"; + +type Omission = { + id: string; + in_full_tree: boolean; + in_slice: boolean; + title_en: string; + title_zh: string; + reason_en: string; + reason_zh: string; +}; + +type AppRoute = { + source_shell: string; + route: string; + component: string; + catalog_key: string; + kind: string; + host_route: string; + host_route_exists: boolean; + in_slice: boolean; + in_full_tree: boolean; +}; + +type Manifest = { + mode: string; + licenses: { openxyos_tree: string; host_bridge_slice: string }; + shared_org_ui_modules: string[]; + host_organization_routes: string[]; + openxyos_app_routes: AppRoute[]; + omissions: Omission[]; +}; + +const data = manifest as Manifest; + +export function CoveragePage(props: { locale: "zh" | "en" }) { + const zh = props.locale === "zh"; + const appOnly = data.openxyos_app_routes.filter((row) => row.source_shell === "App.tsx"); + return ( +
+ + {zh ? "导出对照(相对桌面组织模块)" : "Export coverage vs desktop org modules"} + +

+ {zh + ? `当前包模式 ${data.mode}。许可证:openXYOS 树 ${data.licenses.openxyos_tree},宿主桥 slice ${data.licenses.host_bridge_slice}。` + : `Pack mode ${data.mode}. Licenses: openXYOS tree ${data.licenses.openxyos_tree}, host-bridge slice ${data.licenses.host_bridge_slice}.`} +

+ {zh ? "宿主 org-ui 切片" : "Host org-ui slices"} +
    + {data.shared_org_ui_modules.map((key) => ( +
  • + {key} +
  • + ))} +
+ {zh ? "桌面 /organization 路由" : "Desktop /organization routes"} +
    + {data.host_organization_routes.map((route) => ( +
  • + {route} +
  • + ))} +
+ {zh ? "App.tsx 模块" : "App.tsx modules"} +
+ + + + + + + + + + + + {appOnly.map((row) => ( + + + + + + + + ))} + +
{zh ? "路由" : "Route"}{zh ? "组件" : "Component"}{zh ? "完整树" : "Full tree"}Slice{zh ? "宿主路由" : "Host route"}
+ {row.route} + {row.component}{row.in_full_tree ? "yes" : "no"}{row.in_slice ? "yes" : "no"} + {row.host_route} + {row.host_route_exists ? "" : zh ? "(宿主未挂)" : " (not on host)"} +
+
+ {zh ? "有意省略" : "Intentional omissions"} +
    + {data.omissions.map((item) => ( +
  • + {zh ? item.title_zh : item.title_en} + + {zh ? "完整树" : "full"}: {item.in_full_tree ? "yes" : "no"} · slice:{" "} + {item.in_slice ? "yes" : "no"} + +

    {zh ? item.reason_zh : item.reason_en}

    +
  • + ))} +
+

+ {zh ? "机器可读清单见 " : "Machine-readable manifest: "} + src/modules.json +

+
+ ); +} diff --git a/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx b/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx index 2ebc8093..d9af00b8 100644 --- a/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx +++ b/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx @@ -7,10 +7,10 @@ const COPY: Record< { title: { zh: string; en: string }; body: { zh: string; en: string } } > = { chat: { - title: { zh: "沟通协作未迁入独立站", en: "Chat is not in this package" }, + title: { zh: "本切片不含组织沟通;工作室对话留在宿主", en: "Org chat is in the full pack; studio chat stays on the host" }, body: { - zh: "对话运行时留在 FreeOS / Octop。请打开宿主 Dashboard 的 /chat,不要在独立站里找第二套 Chat。", - en: "Conversation stays on FreeOS / Octop. Open /chat on the host Dashboard — this export does not ship a second chat runtime.", + zh: "组织沟通协作在完整导出的 openxyos/ 树(App.tsx /chat)。本 slice 不跑组织会话。FreeOS / Octop 工作室智能体对话留在宿主 Dashboard /chat,本包不导出第二套智能体运行时。", + en: "Organization collaboration chat ships in the full export’s openxyos/ tree (App.tsx /chat). This slice does not run org rooms. FreeOS / Octop studio agent chat stays on the host Dashboard /chat — this pack does not ship a second agent runtime.", }, }, experts: { diff --git a/scripts/org-export/template/src/shell/Nav.tsx b/scripts/org-export/template/src/shell/Nav.tsx index eb216d25..79452959 100644 --- a/scripts/org-export/template/src/shell/Nav.tsx +++ b/scripts/org-export/template/src/shell/Nav.tsx @@ -15,6 +15,7 @@ const LINKS: { to: string; en: string; zh: string }[] = [ { to: "/reflections", en: "Reflections", zh: "反思" }, { to: "/governance", en: "Governance", zh: "治理" }, { to: "/settings", en: "Settings", zh: "设置" }, + { to: "/coverage", en: "Coverage", zh: "对照" }, ]; export function StandaloneNav(props: { diff --git a/scripts/org-export/template/tsconfig.app.json b/scripts/org-export/template/tsconfig.app.json index bdcd7193..b74b6889 100644 --- a/scripts/org-export/template/tsconfig.app.json +++ b/scripts/org-export/template/tsconfig.app.json @@ -5,6 +5,7 @@ "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", + "resolveJsonModule": true, "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 065fe0ac..a78abec8 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -381,16 +381,29 @@ def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None: required=True, help="Output directory for the standalone Organization web package.", ) -def export_standalone(out_dir: Path) -> None: - """Export a runnable standalone Organization web from shared org-ui. +@click.option( + "--mode", + type=click.Choice(["full", "slice"], case_sensitive=False), + default="full", + show_default=True, + help=( + "full: commercializable openXYOS source tree plus host-bridge slice. " + "slice: org-ui SPA that proxies to a FreeOS host." + ), +) +def export_standalone(out_dir: Path, mode: str) -> None: + """Export a commercializable openXYOS source pack (default: full). - Copies dashboard/src/org-ui plus a Vite + Docker shell (local JWT - IdentityBridge, /api proxy to a FreeOS host). Chat is not exported. + full copies modules/openxyos (Apache-2.0 App.tsx surface) plus the + MIT host-bridge slice. slice is the thinner org-ui SPA that still + proxies /api to FreeOS. Studio agent chat is not exported. Default FreeOS installers stay zero-Node; this export is opt-in. """ + from octop.modules.org_os.export_inventory import ExportMode from octop.modules.org_os.export_standalone import write_standalone_scaffold - written = write_standalone_scaffold(out_dir) + chosen: ExportMode = "full" if mode.lower() == "full" else "slice" + written = write_standalone_scaffold(out_dir, mode=chosen) click.echo(json.dumps(written, indent=2)) diff --git a/src/octop/modules/org_os/export_inventory.py b/src/octop/modules/org_os/export_inventory.py new file mode 100644 index 00000000..a69eec5e --- /dev/null +++ b/src/octop/modules/org_os/export_inventory.py @@ -0,0 +1,344 @@ +"""Route/module inventory for ``freeos org export-standalone``. + +Compares the desktop host Organization routes, shared org-ui slices, and +the vendored openXYOS ``App.tsx`` / ``OpenApp.tsx`` trees. Used as +``modules.json`` in the export pack so a customer can see what shipped. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Literal + +from octop.modules.org_os.catalog import catalog_keys +from octop.modules.org_os.contract import SHARED_ORG_UI_MODULES + +ExportMode = Literal["full", "slice"] + +_ROUTE = re.compile(r'', re.S) +_LAZY = re.compile(r'(\w+)\s*=\s*lazy\(\s*\(\)\s*=>\s*import\("(\./pages/[^"]+)"\)') +_HOST_ROUTE = re.compile(r'path:\s*"(/organization[^"]*)"') +_APP_MODULE_ROUTE = re.compile(r'prefix:\s*"([^"]+)"\s*,\s*moduleKey:\s*"([^"]+)"') + +# App.tsx MODULE_ROUTES plus Open-12 / commercial aliases used in the inventory. +_ROUTE_KEY: dict[str, str] = { + "/": "dashboard", + "/dashboard": "dashboard", + "/app": "workspace", + "/workspace": "workspace", + "/announcements": "announcements", + "/org": "organization", + "/employees": "employees", + "/skills": "skills", + "/chat": "chat", + "/agents": "agents", + "/tasks": "tasks", + "/workflows": "workflows", + "/contracts": "contracts", + "/assets": "assets", + "/attendance": "attendance", + "/leave": "attendance", + "/expense": "expenses", + "/daily-report": "work_records", + "/goals": "goals", + "/budgets": "budgets", + "/routines": "routines", + "/performance": "performance", + "/efficiency": "efficiency", + "/reflections": "reflections", + "/knowledge": "knowledge", + "/governance": "governance", + "/audit": "audit", + "/settings": "settings", + "/admin": "admin", + "/customers": "customers", + "/electricity": "electricity", +} + +_HOST_ALIAS = {"/": "dashboard", "/app": "workspace", "/dashboard": "dashboard"} + +_OMISSIONS: tuple[dict[str, Any], ...] = ( + { + "id": "freeos_agent_chat", + "in_full_tree": False, + "in_slice": False, + "title_en": "FreeOS / Octop agent chat (studio)", + "title_zh": "FreeOS / Octop 智能体对话(工作室)", + "reason_en": ( + "Everyday studio conversations, IM, cron, and sandboxes stay on the " + "FreeOS host. This pack does not export a second agent runtime." + ), + "reason_zh": ( + "工作室日常对话、IM、定时任务和沙箱留在 FreeOS 宿主。本包不导出第二套智能体运行时。" + ), + }, + { + "id": "org_collaboration_chat", + "in_full_tree": True, + "in_slice": False, + "title_en": "Organization collaboration chat", + "title_zh": "组织沟通协作", + "reason_en": ( + "Ships in the full openXYOS tree (App.tsx / OpenApp.tsx /chat). " + "The host-bridge slice only shows a stub: org rooms need the Node " + "app, and must not replace studio agent chat." + ), + "reason_zh": ( + "完整 openXYOS 树(App.tsx / OpenApp.tsx 的 /chat)里有。" + "宿主桥 slice 只有说明页:组织会话需要 Node 应用," + "且不得替换工作室智能体对话。" + ), + }, + { + "id": "desktop_iframe_nsis", + "in_full_tree": False, + "in_slice": False, + "title_en": "Desktop iframe / NSIS / managed Node runtime", + "title_zh": "桌面 iframe / NSIS / 托管 Node 运行时", + "reason_en": ( + "Managed Node + iframe is a FreeOS desktop transition bridge, " + "not part of the commercial source pack. Default installers stay " + "zero-Node." + ), + "reason_zh": ( + "托管 Node + iframe 是 FreeOS 桌面的过渡桥,不是本商业源码包的一部分。" + "默认安装器仍为零 Node。" + ), + }, +) + + +def _key_for_route(route: str) -> str: + if route in _ROUTE_KEY: + return _ROUTE_KEY[route] + for prefix, key in sorted(_ROUTE_KEY.items(), key=lambda item: -len(item[0])): + if prefix != "/" and (route == prefix or route.startswith(prefix + "/")): + return key + return route.strip("/").split("/")[0] or "unknown" + + +def _host_route_for(route: str) -> str: + slug = _HOST_ALIAS.get(route, route.lstrip("/")) + return "/organization/" + slug + + +def _kind_for(key: str, open12: set[str]) -> str: + if key in open12: + return "open12" + return "commercial" + + +def parse_shell_routes(path: Path) -> list[dict[str, str]]: + text = path.read_text(encoding="utf-8") + components = dict(_LAZY.findall(text)) + rows: list[dict[str, str]] = [] + seen: set[str] = set() + for route, expression in _ROUTE.findall(text): + if route in seen: + continue + symbols = re.findall(r"<(\w+)", expression) + component = next((symbol for symbol in symbols if symbol in components), None) + if component is None: + continue + seen.add(route) + rows.append( + { + "route": route, + "component": component, + "source_file": components[component], + } + ) + return rows + + +def host_organization_routes(root: Path) -> list[str]: + host = root / "dashboard" / "src" / "routes" / "index.tsx" + if not host.is_file(): + return [] + return sorted(set(_HOST_ROUTE.findall(host.read_text(encoding="utf-8")))) + + +def app_module_route_keys(root: Path) -> dict[str, str]: + app = root / "modules" / "openxyos" / "frontend" / "src" / "App.tsx" + mapping = dict(_ROUTE_KEY) + if app.is_file(): + mapping.update(_APP_MODULE_ROUTE.findall(app.read_text(encoding="utf-8"))) + return mapping + + +def host_org_ui_pages() -> dict[str, Any]: + """Existing org-ui slice page map (host + slice SPA).""" + return { + "announcements": { + "component": "AnnouncementPage", + "embedded_route": "/organization/announcements", + "standalone_route": "/announcements", + "api": "/api/org-module/announcements", + }, + "organization": { + "component": "OrgChartPage", + "embedded_route": "/organization/org", + "standalone_route": "/org", + "api": "/api/org-module/org", + }, + "employees": { + "component": "EmployeesPage", + "detail_component": "EmployeeDetailPage", + "embedded_route": "/organization/employees", + "standalone_route": "/employees", + "api": "/api/org-module/org/employees", + "store": "{FREEOS_HOME}/org/org_chart.sqlite", + }, + "skills": { + "component": "SkillsPage", + "embedded_route": "/organization/skills", + "standalone_route": "/skills", + "api": "/api/org-module/skills", + "store": "{FREEOS_HOME}/org-skills", + }, + "governance": { + "component": "GovernancePage", + "embedded_route": "/organization/governance", + "standalone_route": "/governance", + "api": "/api/org-module/governance", + "store": "{FREEOS_HOME}/governance", + }, + "knowledge": { + "component": "KnowledgePage", + "embedded_route": "/organization/knowledge", + "standalone_route": "/knowledge", + "api": "/api/org-module/knowledge", + "store": "host knowledge_bases / knowledge_documents", + }, + "tasks": { + "component": "TasksPage", + "detail_component": "TaskDetailPage", + "embedded_route": "/organization/tasks", + "standalone_route": "/tasks", + "api": "/api/org-module/tasks", + "store": "{FREEOS_HOME}/org/tasks.sqlite", + }, + "reflections": { + "component": "ReflectionsPage", + "embedded_route": "/organization/reflections", + "standalone_route": "/reflections", + "api": "/api/org-module/reflections", + "store": "{FREEOS_HOME}/org/reflections.sqlite", + }, + "settings": { + "component": "SettingsPage", + "embedded_route": "/organization/settings", + "standalone_route": "/settings", + "api": "/api/org-module/settings", + "modules_api": "/api/org-module/modules", + "prefs_api": "/api/org-module/prefs", + "store": "{FREEOS_HOME}/org-os/prefs.json", + }, + "agents": { + "component": "AgentsPage", + "embedded_route": "/organization/agents", + "standalone_route": "/agents", + "api": "/api/org-module/agents", + "compile_api": "/api/org-module/blueprints/compile", + "transition_api": "/api/org-module/employees/transition", + "spawn_api": "/api/org-module/employees/spawn", + "store": "{FREEOS_HOME}/tenants//employees", + }, + "workspace": { + "component": "WorkspacePage", + "embedded_route": "/organization/workspace", + "standalone_route": "/app", + "api": "/api/org-module/overview", + "not_migrated": ["chat"], + }, + } + + +def build_export_manifest(root: Path, *, mode: ExportMode) -> dict[str, Any]: + open12 = set(catalog_keys()) + shared = list(SHARED_ORG_UI_MODULES) + host_routes = host_organization_routes(root) + frontend = root / "modules" / "openxyos" / "frontend" / "src" + app_rows: list[dict[str, Any]] = [] + for shell in ("App.tsx", "OpenApp.tsx"): + path = frontend / shell + if not path.is_file(): + continue + for row in parse_shell_routes(path): + key = _key_for_route(row["route"]) + host = _host_route_for(row["route"]) + in_slice = key in SHARED_ORG_UI_MODULES + kind = _kind_for(key, open12) + app_rows.append( + { + "source_shell": shell, + "route": row["route"], + "component": row["component"], + "source_file": row["source_file"], + "catalog_key": key, + "kind": kind, + "host_route": host, + "host_route_exists": host in host_routes, + "in_slice": in_slice, + "in_full_tree": True, + } + ) + return { + "pack": "openxyos-source", + "mode": mode, + "product": "FreeOS commercializable openXYOS source pack (P1.2)", + "licenses": { + "openxyos_tree": "Apache-2.0", + "host_bridge_slice": "MIT", + }, + "copied_this_export": { + "openxyos": mode == "full", + "slice": True, + }, + "shared_org_ui_modules": shared, + "open12_catalog_keys": list(open12), + "host_organization_routes": host_routes, + "openxyos_app_routes": app_rows, + "omissions": [dict(item) for item in _OMISSIONS], + "host_proxied_in_slice": [ + "/api/org-module/*", + "/api/auth/login (slice bridge only; not the org-room end state)", + ], + "full_tree_self_contained": ( + "openxyos/ is the Apache-2.0 Node+Vite app; npm ci && npm run dev " + "runs App.tsx without FreeOS. Slice still proxies to FREEOS_UPSTREAM." + ), + "import": "dashboard/src/org-ui", + "identity": { + "embedded": "FreeOS Dashboard session (auth_token) — everyday studio", + "standalone": "local JWT (openxyos.standalone.jwt)", + "bridge": "org-ui createLocalJwtBridge", + "slice_login": ( + "slice posts to host /api/auth/login as an interim bridge; " + "not the organization-room end state" + ), + "full_tree_org_room": ( + "openxyos AuthPage talks to its own /api/auth (organization room)" + ), + }, + "api": { + "interim": ( + "slice same-origin /api proxied to FREEOS_UPSTREAM (FreeOS /api/org-module)" + ), + "target": ( + "full tree: self-contained openXYOS Express APIs in openxyos/; " + "slice still proxies until a customer replaces the bridge" + ), + }, + "pages": host_org_ui_pages(), + "not_exported": ( + ["freeos_agent_chat"] + if mode == "full" + else ["org_collaboration_chat", "freeos_agent_chat", "commercial_app_verticals"] + ), + "todo": ( + "optional self-contained org API inside the slice; unify " + "POST /api/org-module/source/download with this generated tree" + ), + } diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 9bf8ab73..8d438077 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -1,4 +1,4 @@ -"""``freeos org export-standalone`` — runnable Vite package from org-ui.""" +"""``freeos org export-standalone`` — commercializable openXYOS source pack.""" from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Any from octop.modules.org_os.contract import SHARED_ORG_UI_MODULES +from octop.modules.org_os.export_inventory import ExportMode, build_export_manifest _ORG_UI_IGNORE = ( "*.test.ts", @@ -18,6 +19,57 @@ ".DS_Store", ) +_SKIP_DIR_NAMES = { + "node_modules", + "oh_modules", + "dist", + "build", + "coverage", + ".git", + ".idea", + ".vscode", + ".hvigor", + ".tmp", + "tmp", + "artifacts", + "reports", + "uploads", + "__pycache__", +} +_SKIP_FILES = {".DS_Store", "Thumbs.db", ".env"} +_SKIP_SUFFIXES = ( + ".db", + ".sqlite", + ".sqlite3", + ".log", + ".pyc", + ".zip", + ".tar", + ".tgz", + ".bak", + ".pem", + ".key", + ".p12", + ".pfx", +) + +_SLICE_FILES = ( + "README.md", + "LICENSE", + "package.json", + "vite.config.ts", + "Dockerfile", + "docker-compose.yml", + "nginx.conf.template", + "server/proxy.mjs", + "src/App.tsx", + "src/modules.json", + "src/org-ui/index.ts", + "src/org-ui/bridges/localJwt.ts", + "src/auth/LoginPage.tsx", + "src/shell/CoveragePage.tsx", +) + def _repo_root() -> Path: here = Path(__file__).resolve() @@ -29,123 +81,40 @@ def _repo_root() -> Path: return cwd raise FileNotFoundError( "freeos org export-standalone needs a FreeOS source checkout " - "(dashboard/src/org-ui and scripts/org-export/template). " + "(dashboard/src/org-ui, scripts/org-export/template, modules/openxyos). " "Installed wheels without those trees cannot generate the package." ) def _looks_like_checkout(root: Path) -> bool: - return (root / "dashboard" / "src" / "org-ui").is_dir() and ( - root / "scripts" / "org-export" / "template" - ).is_dir() + return ( + (root / "dashboard" / "src" / "org-ui").is_dir() + and (root / "scripts" / "org-export" / "template").is_dir() + and (root / "modules" / "openxyos" / "frontend" / "src" / "App.tsx").is_file() + ) -def _modules_manifest() -> dict[str, Any]: - return { - "shared_org_ui_modules": list(SHARED_ORG_UI_MODULES), - "import": "dashboard/src/org-ui", - "identity": { - "embedded": "FreeOS Dashboard session (auth_token)", - "standalone": "local JWT (openxyos.standalone.jwt)", - "bridge": "org-ui createLocalJwtBridge", - }, - "api": { - "interim": "same-origin /api proxied to FREEOS_UPSTREAM (FreeOS /api/org-module)", - "target": "self-contained server implementing /api/org-module/* in this package", - }, - "pages": { - "announcements": { - "component": "AnnouncementPage", - "embedded_route": "/organization/announcements", - "standalone_route": "/announcements", - "api": "/api/org-module/announcements", - }, - "organization": { - "component": "OrgChartPage", - "embedded_route": "/organization/org", - "standalone_route": "/org", - "api": "/api/org-module/org", - }, - "employees": { - "component": "EmployeesPage", - "detail_component": "EmployeeDetailPage", - "embedded_route": "/organization/employees", - "standalone_route": "/employees", - "api": "/api/org-module/org/employees", - "store": "{FREEOS_HOME}/org/org_chart.sqlite", - }, - "skills": { - "component": "SkillsPage", - "embedded_route": "/organization/skills", - "standalone_route": "/skills", - "api": "/api/org-module/skills", - "store": "{FREEOS_HOME}/org-skills", - }, - "governance": { - "component": "GovernancePage", - "embedded_route": "/organization/governance", - "standalone_route": "/governance", - "api": "/api/org-module/governance", - "store": "{FREEOS_HOME}/governance", - }, - "knowledge": { - "component": "KnowledgePage", - "embedded_route": "/organization/knowledge", - "standalone_route": "/knowledge", - "api": "/api/org-module/knowledge", - "store": "host knowledge_bases / knowledge_documents", - }, - "tasks": { - "component": "TasksPage", - "detail_component": "TaskDetailPage", - "embedded_route": "/organization/tasks", - "standalone_route": "/tasks", - "api": "/api/org-module/tasks", - "store": "{FREEOS_HOME}/org/tasks.sqlite", - }, - "reflections": { - "component": "ReflectionsPage", - "embedded_route": "/organization/reflections", - "standalone_route": "/reflections", - "api": "/api/org-module/reflections", - "store": "{FREEOS_HOME}/org/reflections.sqlite", - }, - "settings": { - "component": "SettingsPage", - "embedded_route": "/organization/settings", - "standalone_route": "/settings", - "api": "/api/org-module/settings", - "modules_api": "/api/org-module/modules", - "prefs_api": "/api/org-module/prefs", - "store": "{FREEOS_HOME}/org-os/prefs.json", - }, - "agents": { - "component": "AgentsPage", - "embedded_route": "/organization/agents", - "standalone_route": "/agents", - "api": "/api/org-module/agents", - "compile_api": "/api/org-module/blueprints/compile", - "transition_api": "/api/org-module/employees/transition", - "spawn_api": "/api/org-module/employees/spawn", - "store": "{FREEOS_HOME}/tenants//employees", - }, - "workspace": { - "component": "WorkspacePage", - "embedded_route": "/organization/workspace", - "standalone_route": "/app", - "api": "/api/org-module/overview", - "not_migrated": ["chat"], - }, - }, - "not_exported": ["chat"], - "todo": "self-contained org API (export still proxies to FREEOS_UPSTREAM); optional packages/org-ui extraction", - } +def _ignore_openxyos(directory: str, names: list[str]) -> set[str]: + del directory + skipped: set[str] = set() + for name in names: + if name in _SKIP_DIR_NAMES or name in _SKIP_FILES: + skipped.add(name) + continue + if name.startswith(".env") and name != ".env.example": + skipped.add(name) + continue + if name.endswith(_SKIP_SUFFIXES): + skipped.add(name) + return skipped -def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: - dest = Path(out_dir) - dest.mkdir(parents=True, exist_ok=True) - root = _repo_root() +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + +def _write_slice(root: Path, dest: Path, manifest: dict[str, Any]) -> None: template = root / "scripts" / "org-export" / "template" org_ui = root / "dashboard" / "src" / "org-ui" shutil.copytree(template, dest, dirs_exist_ok=True, ignore=shutil.ignore_patterns(".DS_Store")) @@ -155,31 +124,89 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: dirs_exist_ok=True, ignore=shutil.ignore_patterns(*_ORG_UI_IGNORE), ) - src = dest / "src" - src.mkdir(parents=True, exist_ok=True) - (src / "modules.json").write_text( - json.dumps(_modules_manifest(), indent=2) + "\n", - encoding="utf-8", + _write_json(dest / "src" / "modules.json", manifest) + _write_json(dest / "modules.json", manifest) + + +def _write_openxyos_tree(root: Path, dest: Path) -> None: + source = root / "modules" / "openxyos" + shutil.copytree( + source, + dest, + dirs_exist_ok=True, + ignore=_ignore_openxyos, + ) + + +def _copy_pack_overlay(root: Path, dest: Path) -> None: + overlay = root / "scripts" / "org-export" / "pack" + if not overlay.is_dir(): + return + shutil.copytree( + overlay, + dest, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(".DS_Store"), ) - files = [ - "README.md", - "package.json", - "vite.config.ts", - "Dockerfile", - "docker-compose.yml", - "nginx.conf.template", - "server/proxy.mjs", - "src/App.tsx", - "src/modules.json", - "src/org-ui/index.ts", - "src/org-ui/bridges/localJwt.ts", - "src/auth/LoginPage.tsx", - ] + + +def write_standalone_scaffold( + out_dir: Path, + *, + mode: ExportMode = "full", +) -> dict[str, Any]: + dest = Path(out_dir) + dest.mkdir(parents=True, exist_ok=True) + root = _repo_root() + manifest = build_export_manifest(root, mode=mode) + if mode == "slice": + _write_slice(root, dest, manifest) + files = list(_SLICE_FILES) + layout = {"slice": ".", "manifest": "src/modules.json"} + api = ( + "proxies /api to FREEOS_UPSTREAM (interim slice); " + "use --mode full for the self-contained openXYOS source tree" + ) + else: + _write_openxyos_tree(root, dest / "openxyos") + _write_slice(root, dest / "slice", manifest) + _copy_pack_overlay(root, dest) + _write_json(dest / "modules.json", manifest) + files = [ + "README.md", + "README.zh-CN.md", + "NOTICE", + "modules.json", + "openxyos/package.json", + "openxyos/LICENSE", + "openxyos/frontend/src/App.tsx", + "openxyos/backend/server.ts", + "slice/README.md", + "slice/package.json", + "slice/src/App.tsx", + "slice/src/modules.json", + "slice/src/org-ui/index.ts", + "slice/src/org-ui/bridges/localJwt.ts", + "slice/src/shell/CoveragePage.tsx", + ] + layout = { + "openxyos": "openxyos/", + "slice": "slice/", + "manifest": "modules.json", + } + api = ( + "full tree is self-contained Node+Vite in openxyos/; " + "slice proxies /api to FREEOS_UPSTREAM (interim host bridge)" + ) return { "out_dir": str(dest), + "mode": mode, + "layout": layout, "modules": list(SHARED_ORG_UI_MODULES), "files": files, + "licenses": dict(manifest["licenses"]), + "omissions": [item["id"] for item in manifest["omissions"]], "auth": "standalone local JWT (openxyos.standalone.jwt)", - "api": "proxies /api to FREEOS_UPSTREAM (interim); self-contained server is the target end-state", - "todo": "self-contained org API; optional packages/org-ui extraction", + "api": api, + "todo": manifest["todo"], } diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 8a2a59f4..83732f63 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -1,4 +1,4 @@ -"""``freeos org export-standalone`` writes a runnable org-ui package.""" +"""``freeos org export-standalone`` writes a commercializable openXYOS pack.""" from __future__ import annotations @@ -9,132 +9,149 @@ from octop.cli.main import cli +_SHARED = [ + "announcements", + "organization", + "employees", + "skills", + "governance", + "knowledge", + "tasks", + "reflections", + "settings", + "agents", + "workspace", +] -def _export(tmp_path: Path) -> tuple[Path, dict[str, object]]: - out = tmp_path / "openxyos-web" + +def _export( + tmp_path: Path, *extra: str, dest_name: str = "openxyos-web" +) -> tuple[Path, dict[str, object]]: + out = tmp_path / dest_name runner = CliRunner() - result = runner.invoke(cli, ["org", "export-standalone", "--out", str(out)]) + result = runner.invoke(cli, ["org", "export-standalone", "--out", str(out), *extra]) assert result.exit_code == 0, result.output return out, json.loads(result.output) -def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: - out, payload = _export(tmp_path) - assert payload["modules"] == [ - "announcements", - "organization", - "employees", - "skills", - "governance", - "knowledge", - "tasks", - "reflections", - "settings", - "agents", - "workspace", - ] - assert payload["auth"] == "standalone local JWT (openxyos.standalone.jwt)" - assert "FREEOS_UPSTREAM" in str(payload["api"]) - modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) - assert modules["shared_org_ui_modules"] == [ - "announcements", - "organization", - "employees", - "skills", - "governance", - "knowledge", - "tasks", - "reflections", - "settings", - "agents", - "workspace", - ] - assert modules["not_exported"] == ["chat"] - assert modules["identity"]["standalone"] == "local JWT (openxyos.standalone.jwt)" - assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" - assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" - assert modules["pages"]["organization"]["component"] == "OrgChartPage" - assert modules["pages"]["organization"]["embedded_route"] == "/organization/org" - assert modules["pages"]["employees"]["component"] == "EmployeesPage" - assert modules["pages"]["employees"]["embedded_route"] == "/organization/employees" - assert modules["pages"]["employees"]["api"] == "/api/org-module/org/employees" - assert modules["pages"]["skills"]["component"] == "SkillsPage" - assert modules["pages"]["skills"]["embedded_route"] == "/organization/skills" - assert modules["pages"]["skills"]["api"] == "/api/org-module/skills" - assert modules["pages"]["governance"]["component"] == "GovernancePage" - assert modules["pages"]["governance"]["embedded_route"] == "/organization/governance" - assert modules["pages"]["governance"]["api"] == "/api/org-module/governance" - assert modules["pages"]["knowledge"]["component"] == "KnowledgePage" - assert modules["pages"]["knowledge"]["embedded_route"] == "/organization/knowledge" - assert modules["pages"]["knowledge"]["api"] == "/api/org-module/knowledge" - assert modules["pages"]["tasks"]["component"] == "TasksPage" - assert modules["pages"]["tasks"]["embedded_route"] == "/organization/tasks" - assert modules["pages"]["tasks"]["api"] == "/api/org-module/tasks" - assert modules["pages"]["tasks"]["store"] == "{FREEOS_HOME}/org/tasks.sqlite" - assert modules["pages"]["reflections"]["component"] == "ReflectionsPage" - assert modules["pages"]["reflections"]["embedded_route"] == "/organization/reflections" - assert modules["pages"]["reflections"]["api"] == "/api/org-module/reflections" - assert modules["pages"]["reflections"]["store"] == "{FREEOS_HOME}/org/reflections.sqlite" - assert modules["pages"]["settings"]["component"] == "SettingsPage" - assert modules["pages"]["settings"]["embedded_route"] == "/organization/settings" - assert modules["pages"]["settings"]["api"] == "/api/org-module/settings" - assert modules["pages"]["settings"]["modules_api"] == "/api/org-module/modules" - assert modules["pages"]["settings"]["prefs_api"] == "/api/org-module/prefs" - assert modules["pages"]["settings"]["store"] == "{FREEOS_HOME}/org-os/prefs.json" - assert modules["pages"]["agents"]["component"] == "AgentsPage" - assert modules["pages"]["agents"]["embedded_route"] == "/organization/agents" - assert modules["pages"]["agents"]["api"] == "/api/org-module/agents" - assert modules["pages"]["agents"]["compile_api"] == "/api/org-module/blueprints/compile" - assert modules["pages"]["agents"]["store"] == "{FREEOS_HOME}/tenants//employees" - assert modules["pages"]["workspace"]["component"] == "WorkspacePage" - assert modules["pages"]["workspace"]["embedded_route"] == "/organization/workspace" - assert modules["pages"]["workspace"]["standalone_route"] == "/app" - assert modules["pages"]["workspace"]["api"] == "/api/org-module/overview" - assert modules["pages"]["workspace"]["not_migrated"] == ["chat"] - app = (out / "src" / "App.tsx").read_text(encoding="utf-8") - assert "AnnouncementPage" in app - assert "OrgChartPage" in app - assert "EmployeesPage" in app - assert "EmployeeDetailPage" in app - assert "SkillsPage" in app - assert "GovernancePage" in app - assert "KnowledgePage" in app - assert "TasksPage" in app - assert "TaskDetailPage" in app - assert "ReflectionsPage" in app - assert "SettingsPage" in app - assert "AgentsPage" in app - assert "WorkspacePage" in app - assert "createLocalJwtBridge" in app +def _assert_org_ui_slice(slice_root: Path) -> None: + app = (slice_root / "src" / "App.tsx").read_text(encoding="utf-8") + for name in ( + "AnnouncementPage", + "OrgChartPage", + "EmployeesPage", + "EmployeeDetailPage", + "SkillsPage", + "GovernancePage", + "KnowledgePage", + "TasksPage", + "TaskDetailPage", + "ReflectionsPage", + "SettingsPage", + "AgentsPage", + "WorkspacePage", + "CoveragePage", + "createLocalJwtBridge", + "HostDeepLinkPage", + ): + assert name in app assert 'from "org-ui"' in app assert 'path="/chat"' in app - assert "HostDeepLinkPage" in app - readme = (out / "README.md").read_text(encoding="utf-8") + assert 'path="/coverage"' in app + readme = (slice_root / "README.md").read_text(encoding="utf-8") assert "AnnouncementPage" in readme - assert "OrgChartPage" in readme - assert "EmployeesPage" in readme - assert "SkillsPage" in readme - assert "GovernancePage" in readme - assert "KnowledgePage" in readme - assert "TasksPage" in readme - assert "ReflectionsPage" in readme - assert "SettingsPage" in readme - assert "AgentsPage" in readme assert "WorkspacePage" in readme - assert "Chat is **not** migrated" in readme assert "openxyos.standalone.jwt" in readme assert "docker compose" in readme assert "FREEOS_UPSTREAM" in readme + assert "FreeOS / Octop agent chat" in readme or "工作室智能体对话" in readme + assert (slice_root / "src" / "org-ui" / "pages" / "workspace" / "WorkspacePage.tsx").is_file() + assert (slice_root / "src" / "org-ui" / "bridges" / "localJwt.ts").is_file() + assert list((slice_root / "src" / "org-ui").rglob("*.test.tsx")) == [] + license_text = (slice_root / "LICENSE").read_text(encoding="utf-8") + assert "MIT License" in license_text + deeplink = (slice_root / "src" / "shell" / "HostDeepLinkPage.tsx").read_text(encoding="utf-8") + assert "second agent runtime" in deeplink + coverage = (slice_root / "src" / "shell" / "CoveragePage.tsx").read_text(encoding="utf-8") + assert "modules.json" in coverage + + +def test_export_standalone_default_is_full_source_pack(tmp_path: Path) -> None: + out, payload = _export(tmp_path) + assert payload["mode"] == "full" + assert payload["modules"] == _SHARED + assert payload["auth"] == "standalone local JWT (openxyos.standalone.jwt)" + assert payload["licenses"]["openxyos_tree"] == "Apache-2.0" + assert payload["licenses"]["host_bridge_slice"] == "MIT" + assert "freeos_agent_chat" in payload["omissions"] + assert (out / "openxyos" / "frontend" / "src" / "App.tsx").is_file() + assert (out / "openxyos" / "backend" / "server.ts").is_file() + assert (out / "openxyos" / "package.json").is_file() + apache = (out / "openxyos" / "LICENSE").read_text(encoding="utf-8") + assert "Apache License" in apache + app = (out / "openxyos" / "frontend" / "src" / "App.tsx").read_text(encoding="utf-8") + assert "ContractPage" in app + assert "AttendancePage" in app + assert "ChatPage" in app + readme = (out / "README.md").read_text(encoding="utf-8") + assert "Apache-2.0" in readme + assert "MIT" in readme + assert "npm ci" in readme + assert "FREEOS_UPSTREAM" in readme + assert "freeos_agent_chat" in readme + zh = (out / "README.zh-CN.md").read_text(encoding="utf-8") + assert "Apache-2.0" in zh + assert "工作室" in zh + notice = (out / "NOTICE").read_text(encoding="utf-8") + assert "Apache-2.0" in notice + assert "MIT" in notice + manifest = json.loads((out / "modules.json").read_text(encoding="utf-8")) + assert manifest["mode"] == "full" + assert manifest["shared_org_ui_modules"] == _SHARED + assert "/organization/announcements" in manifest["host_organization_routes"] + assert "chat" not in _SHARED + routes = { + row["route"]: row + for row in manifest["openxyos_app_routes"] + if row["source_shell"] == "App.tsx" + } + assert routes["/announcements"]["in_slice"] is True + assert routes["/announcements"]["host_route_exists"] is True + assert routes["/chat"]["in_full_tree"] is True + assert routes["/chat"]["in_slice"] is False + assert routes["/contracts"]["kind"] == "commercial" + assert routes["/contracts"]["in_slice"] is False + assert routes["/contracts"]["in_full_tree"] is True + assert any(item["id"] == "freeos_agent_chat" for item in manifest["omissions"]) + assert manifest["not_exported"] == ["freeos_agent_chat"] + assert manifest["pages"]["workspace"]["not_migrated"] == ["chat"] + _assert_org_ui_slice(out / "slice") + slice_manifest = json.loads( + (out / "slice" / "src" / "modules.json").read_text(encoding="utf-8") + ) + assert slice_manifest["openxyos_app_routes"] == manifest["openxyos_app_routes"] + assert not list((out / "openxyos").rglob("node_modules")) -def test_export_standalone_is_runnable_vite_package(tmp_path: Path) -> None: - out, _payload = _export(tmp_path) +def test_export_standalone_slice_mode_keeps_runnable_vite_root(tmp_path: Path) -> None: + out, payload = _export(tmp_path, "--mode", "slice", dest_name="slice-only") + assert payload["mode"] == "slice" + assert payload["modules"] == _SHARED + assert "FREEOS_UPSTREAM" in str(payload["api"]) + assert not (out / "openxyos").exists() + _assert_org_ui_slice(out) + modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) + assert modules["mode"] == "slice" + assert modules["shared_org_ui_modules"] == _SHARED + assert "org_collaboration_chat" in modules["not_exported"] + assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" + assert modules["pages"]["workspace"]["standalone_route"] == "/app" package = json.loads((out / "package.json").read_text(encoding="utf-8")) assert package["scripts"]["dev"] == "vite" assert "vite build" in package["scripts"]["build"] assert package["scripts"]["start"] == "node server/proxy.mjs" vite = (out / "vite.config.ts").read_text(encoding="utf-8") - assert 'alias: {\n "org-ui"' in vite or '"org-ui"' in vite + assert '"org-ui"' in vite assert "FREEOS_UPSTREAM" in vite assert (out / "Dockerfile").is_file() compose = (out / "docker-compose.yml").read_text(encoding="utf-8") @@ -144,16 +161,19 @@ def test_export_standalone_is_runnable_vite_package(tmp_path: Path) -> None: assert "${FREEOS_UPSTREAM}" in nginx proxy = (out / "server" / "proxy.mjs").read_text(encoding="utf-8") assert "FREEOS_UPSTREAM" in proxy - assert "/api" in proxy login = (out / "src" / "auth" / "LoginPage.tsx").read_text(encoding="utf-8") assert "/auth/login" in login - assert "openxyos.standalone.jwt" in login or "setSession" in login - assert (out / "src" / "org-ui" / "index.ts").is_file() - assert (out / "src" / "org-ui" / "bridges" / "localJwt.ts").is_file() - assert (out / "src" / "org-ui" / "pages" / "workspace" / "WorkspacePage.tsx").is_file() - assert (out / "src" / "org-ui" / "pages" / "announcements" / "AnnouncementPage.tsx").is_file() - copied_tests = list((out / "src" / "org-ui").rglob("*.test.tsx")) - assert copied_tests == [] env_example = (out / ".env.example").read_text(encoding="utf-8") assert "VITE_API_BASE=/api" in env_example assert "FREEOS_UPSTREAM" in env_example + + +def test_export_standalone_full_slice_is_runnable_vite_package(tmp_path: Path) -> None: + out, _payload = _export(tmp_path, dest_name="full-pack") + slice_root = out / "slice" + package = json.loads((slice_root / "package.json").read_text(encoding="utf-8")) + assert package["scripts"]["dev"] == "vite" + assert package["scripts"]["start"] == "node server/proxy.mjs" + openxyos_pkg = json.loads((out / "openxyos" / "package.json").read_text(encoding="utf-8")) + assert "dev" in openxyos_pkg["scripts"] + assert (out / "openxyos" / ".env.example").is_file() or (out / "openxyos" / "frontend").is_dir()