diff --git a/CHANGELOG.md b/CHANGELOG.md index 297fa226..0ffa743e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- Organization Phase 4(独立站导出起步):`freeos org export-standalone --out ` 从 `dashboard/src/org-ui` 生成可运行的 Vite 包(OpenApp 风格路由、本地 JWT IdentityBridge `openxyos.standalone.jwt`、登录页、Docker / docker-compose、`server/proxy.mjs` 把 `/api` 反代到 FreeOS `FREEOS_UPSTREAM`)。Chat 不导出。默认安装器与 sidecar 不变(Phase 5)。说明见 `docs/org-export.md`。 - Organization Phase 3(工作台总览切片,Open-12 收口):宿主内薄 Workspace / OpenDashboard。Dashboard `/organization/workspace` 使用共享 `dashboard/src/org-ui` 的 `WorkspacePage`。数据复用已有 `GET /api/org-module/overview`,链到已迁的公告 / 架构 / 员工 / 技能 / 智能体 / 任务 / 知识 / 反思 / 治理 / 设置。**不是**第二套控制面(assemble / pack / loop 仍在 `/organization`)。**Chat 永久不迁。** `freeos org export-standalone` 同时列出 Workspace。Phase 3 Open-12 宿主 UI 完成,下一步 Phase 4 导出。 - Organization Phase 3(智能体切片):宿主内 Agent Studio / 智能体定制。Dashboard `/organization/agents` 使用共享 `dashboard/src/org-ui` 的 `AgentsPage`。列表走 `GET /api/org-module/agents`;编译 / 生命周期 / 注册复用已有 `POST /api/org-module/blueprints/compile`、`/employees/transition`、`/employees/spawn`(`{FREEOS_HOME}/tenants//employees/`)。**不是** FreeOS 个性化智能体编辑器,也不是 Chat,也不依赖未挂载的 sidecar `/api/agent-studio/*`。注册后的同事出现在 Experts。`freeos org export-standalone` 同时列出 Agents。未迁:Chat、资料上传、人才市场。 - Organization Phase 3(设置切片):宿主内 Organization Settings。Dashboard `/organization/settings` 使用共享 `dashboard/src/org-ui` 的 `SettingsPage`。本页只编辑 `org_os` 目录模块开关(复用 `GET/PUT /api/org-module/modules`)与组织本地偏好(`GET /api/org-module/settings` · `PUT /api/org-module/prefs`,`{FREEOS_HOME}/org-os/prefs.json`)。**不是** FreeOS 系统设置:大模型密钥、用户、时区仍链到 `/system-settings`。没有边车,也不复制 sidecar 的 AI/用户/数据库页。`freeos org export-standalone` 同时列出 Settings。未迁:Chat、边车系统设置。 diff --git a/dashboard/src/org-ui/bridges/localJwt.test.ts b/dashboard/src/org-ui/bridges/localJwt.test.ts new file mode 100644 index 00000000..e60db2f3 --- /dev/null +++ b/dashboard/src/org-ui/bridges/localJwt.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; +import { + STANDALONE_SESSION_KEY, + STANDALONE_TOKEN_KEY, + StandaloneUnauthorizedError, + createBridgeFetcher, + createLocalJwtBridge, + sessionFromLoginUser, +} from "./localJwt"; + +function memoryStorage(seed?: Record) { + const data = new Map(Object.entries(seed ?? {})); + return { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { + data.set(key, value); + }, + removeItem: (key: string) => { + data.delete(key); + }, + data, + }; +} + +describe("createLocalJwtBridge", () => { + it("uses a token key distinct from the Dashboard session", () => { + expect(STANDALONE_TOKEN_KEY).toBe("openxyos.standalone.jwt"); + expect(STANDALONE_TOKEN_KEY).not.toBe("auth_token"); + }); + + it("stores local JWT headers and session without host actions", () => { + const storage = memoryStorage(); + const navigate = vi.fn(); + const bridge = createLocalJwtBridge({ + apiBase: "/api", + locale: "zh", + storage, + navigate, + }); + expect(bridge.showHostActions).toBe(false); + expect(bridge.apiBase()).toBe("/api"); + expect(bridge.hasToken()).toBe(false); + expect(bridge.authHeaders()).toEqual({}); + + bridge.setSession("tok-1", { + userId: 7, + displayName: "Ada", + role: "admin", + isAdmin: true, + }); + expect(bridge.hasToken()).toBe(true); + expect(bridge.authHeaders()).toEqual({ Authorization: "Bearer tok-1" }); + expect(bridge.getSession()).toEqual({ + userId: 7, + displayName: "Ada", + role: "admin", + isAdmin: true, + }); + expect(storage.getItem(STANDALONE_TOKEN_KEY)).toBe("tok-1"); + expect(storage.getItem(STANDALONE_SESSION_KEY)).toContain("Ada"); + + bridge.navigate("/org"); + expect(navigate).toHaveBeenCalledWith("/org"); + bridge.clearSession(); + expect(bridge.hasToken()).toBe(false); + expect(bridge.getSession().displayName).toBe(""); + }); + + it("maps a FreeOS login user onto OrgSession", () => { + expect( + sessionFromLoginUser({ + id: 3, + username: "ada", + display_name: "Ada Lovelace", + role: "admin", + }), + ).toEqual({ + userId: 3, + displayName: "Ada Lovelace", + role: "admin", + isAdmin: true, + }); + }); +}); + +describe("createBridgeFetcher", () => { + it("prefixes apiBase and attaches the standalone Bearer token", async () => { + const storage = memoryStorage(); + const bridge = createLocalJwtBridge({ apiBase: "/api", storage }); + bridge.setSession("abc", { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, + }); + const fetchMock = vi.fn(async () => ({ + status: 200, + ok: true, + headers: new Headers({ "content-type": "application/json" }), + json: async () => ({ success: true, data: { count: 1 } }), + })); + vi.stubGlobal("fetch", fetchMock); + const fetchJson = createBridgeFetcher(bridge); + const payload = await fetchJson<{ success: boolean }>( + "/org-module/overview", + ); + expect(payload).toEqual({ success: true, data: { count: 1 } }); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/org-module/overview"); + const headers = new Headers(init.headers); + expect(headers.get("Authorization")).toBe("Bearer abc"); + vi.unstubAllGlobals(); + }); + + it("throws StandaloneUnauthorizedError on 401", async () => { + const bridge = createLocalJwtBridge({ + apiBase: "/api", + storage: memoryStorage(), + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + status: 401, + ok: false, + headers: new Headers(), + text: async () => "nope", + })), + ); + await expect( + createBridgeFetcher(bridge)("/org-module/settings"), + ).rejects.toBeInstanceOf(StandaloneUnauthorizedError); + vi.unstubAllGlobals(); + }); +}); diff --git a/dashboard/src/org-ui/bridges/localJwt.ts b/dashboard/src/org-ui/bridges/localJwt.ts new file mode 100644 index 00000000..a36653ae --- /dev/null +++ b/dashboard/src/org-ui/bridges/localJwt.ts @@ -0,0 +1,175 @@ +/** Standalone IdentityBridge: local JWT, distinct from the Dashboard session. */ + +import type { + IdentityBridge, + OrgFetcher, + OrgLocale, + OrgSession, + ShellAdapter, +} from "../shell"; + +/** Must not reuse Dashboard `auth_token` — standalone keeps its own session. */ +export const STANDALONE_TOKEN_KEY = "openxyos.standalone.jwt"; +export const STANDALONE_SESSION_KEY = "openxyos.standalone.session"; + +export class StandaloneUnauthorizedError extends Error { + constructor(message = "standalone session expired") { + super(message); + this.name = "StandaloneUnauthorizedError"; + } +} + +export interface LocalJwtStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export interface LocalJwtBridge extends ShellAdapter { + setSession(token: string, session: OrgSession): void; + clearSession(): void; + hasToken(): boolean; +} + +const EMPTY_SESSION: OrgSession = { + userId: 0, + displayName: "", + role: "user", + isAdmin: false, +}; + +function memoryStorage(): LocalJwtStorage { + const data = new Map(); + return { + getItem: (key) => data.get(key) ?? null, + setItem: (key, value) => { + data.set(key, value); + }, + removeItem: (key) => { + data.delete(key); + }, + }; +} + +function defaultStorage(): LocalJwtStorage { + try { + if (typeof localStorage !== "undefined") { + return localStorage; + } + } catch { + /* private mode / SSR */ + } + return memoryStorage(); +} + +function readSession(storage: LocalJwtStorage): OrgSession { + const raw = storage.getItem(STANDALONE_SESSION_KEY); + if (!raw) return EMPTY_SESSION; + try { + const parsed = JSON.parse(raw) as Partial; + return { + userId: Number(parsed.userId || 0), + displayName: String(parsed.displayName || ""), + role: String(parsed.role || "user"), + isAdmin: Boolean(parsed.isAdmin), + }; + } catch { + return EMPTY_SESSION; + } +} + +/** + * Local-JWT IdentityBridge for `freeos org export-standalone`. + * Token key is `openxyos.standalone.jwt` — never Dashboard `auth_token`. + */ +export function createLocalJwtBridge(opts?: { + apiBase?: string; + locale?: OrgLocale; + timeZone?: string; + storage?: LocalJwtStorage; + navigate?: (path: string) => void; +}): LocalJwtBridge { + const storage = opts?.storage ?? defaultStorage(); + const apiBase = (opts?.apiBase || "/api").replace(/\/$/, ""); + const locale = opts?.locale ?? "en"; + const timeZone = opts?.timeZone ?? "Asia/Shanghai"; + const navigate = opts?.navigate ?? (() => undefined); + + const bridge: LocalJwtBridge = { + locale, + timeZone, + showHostActions: false, + navigate, + apiBase() { + return apiBase; + }, + getSession() { + return readSession(storage); + }, + authHeaders() { + const headers: Record = {}; + const token = storage.getItem(STANDALONE_TOKEN_KEY) || ""; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; + }, + setSession(token, session) { + storage.setItem(STANDALONE_TOKEN_KEY, token); + storage.setItem(STANDALONE_SESSION_KEY, JSON.stringify(session)); + }, + clearSession() { + storage.removeItem(STANDALONE_TOKEN_KEY); + storage.removeItem(STANDALONE_SESSION_KEY); + }, + hasToken() { + return Boolean(storage.getItem(STANDALONE_TOKEN_KEY)); + }, + }; + return bridge; +} + +/** `createOrgApiClient` adapter: same-origin `/api` + standalone Bearer token. */ +export function createBridgeFetcher(bridge: IdentityBridge): OrgFetcher { + return async (path: string, init?: RequestInit): Promise => { + const base = bridge.apiBase().replace(/\/$/, ""); + const suffix = path.startsWith("/") ? path : `/${path}`; + const headers = new Headers(init?.headers); + for (const [key, value] of Object.entries(bridge.authHeaders())) { + if (!headers.has(key)) headers.set(key, value); + } + const response = await fetch(`${base}${suffix}`, { ...init, headers }); + if (response.status === 401) { + throw new StandaloneUnauthorizedError(); + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `Request failed: ${response.status} ${response.statusText}${ + text ? ` - ${text}` : "" + }`, + ); + } + if (response.status === 204) { + return undefined as T; + } + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json")) { + return (await response.text()) as unknown as T; + } + return (await response.json()) as T; + }; +} + +export function sessionFromLoginUser(user: { + id?: number; + username?: string; + display_name?: string; + role?: string; +}): OrgSession { + const role = user.role || "user"; + return { + userId: Number(user.id || 0), + displayName: user.display_name || user.username || "", + role, + isAdmin: role === "admin", + }; +} diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index 9b11d257..87cb4a14 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -111,3 +111,12 @@ export type { OrgSession, ShellAdapter, } from "./shell"; +export { + STANDALONE_SESSION_KEY, + STANDALONE_TOKEN_KEY, + StandaloneUnauthorizedError, + createBridgeFetcher, + createLocalJwtBridge, + sessionFromLoginUser, +} from "./bridges/localJwt"; +export type { LocalJwtBridge, LocalJwtStorage } from "./bridges/localJwt"; diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index ff4ac2db..6463add7 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -290,7 +290,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 + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `/organization/settings` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; skills list host `org-skills` + Agent skill packages; governance reads host pauses/audit; knowledge bridges host KBs; tasks/reflections live under `{FREEOS_HOME}/org`; settings edit `org-os` catalog toggles + prefs only). Standalone site is **exported** from the same source (`freeos org export-standalone`). 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 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 | ### Auth and tenant mapping diff --git a/docs/org-export.md b/docs/org-export.md new file mode 100644 index 00000000..a41fe9b2 --- /dev/null +++ b/docs/org-export.md @@ -0,0 +1,71 @@ +# 独立组织站导出(Phase 4) + +`freeos org export-standalone --out ` 从 **同一套** `dashboard/src/org-ui` 生成可部署的 openXYOS 风格独立站。这是商业加售的交付物,不是第二份手维护前端。 + +默认 FreeOS 安装器 **不** 因此改成捆绑 Node。Sidecar 仍是可选兼容(`FREEOS_ORG_SIDECAR` / `SHIP_OPENXYOS_RUNTIME`)。安装器瘦身是 **Phase 5**。 + +## 生成 + +在 FreeOS 源码检出里: + +```bash +uv run freeos org export-standalone --out dist/openxyos-web +``` + +输出目录自带: + +- 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` + +**Chat 不导出。** `/chat` 只是深链说明页。 + +模板源在 `scripts/org-export/template/`。改页面请改 `dashboard/src/org-ui`,再重新导出。 + +## 客户怎么跑 + +详见导出目录里的 `README.md`。最短路径: + +```bash +cd dist/openxyos-web +cp .env.example .env +# 把 FREEOS_UPSTREAM 指到已有 FreeOS(默认 http://127.0.0.1:8088) +npm install +npm run dev +``` + +或: + +```bash +FREEOS_UPSTREAM=http://127.0.0.1:8088 docker compose up --build +``` + +浏览器打开 `http://127.0.0.1:3780`,用 **FreeOS 用户** 登录。 + +## 过渡路径 vs 目标终态 + +| | 本波(可演示) | 目标终态 | +|---|---|---| +| UI | 本包 SPA(org-ui) | 同一套 org-ui | +| API | 反向代理到 FreeOS `/api/org-module/*` | 本包自包含服务实现同一合同 | +| 身份 | 本地 JWT,登录打 FreeOS `/api/auth/login` | 客户自己的账号体系发本地 JWT | +| 安装器 | 不变;sidecar 仍可选 | Phase 5:默认安装器零 Node | + +`VITE_API_BASE` 默认 `/api`(同源)。不要在浏览器里直连另一个源的 FreeOS,除非那个宿主已配好 CORS。推荐始终走代理。 + +## 配置 + +| 变量 | 时机 | 含义 | +|---|---|---| +| `VITE_API_BASE` | 构建 | SPA 的 API 前缀,默认 `/api` | +| `FREEOS_UPSTREAM` | 运行 | 拥有 `/api/org-module` 与 `/api/auth` 的 FreeOS 源 | +| `PORT` | `npm start` / Vite | 监听端口,默认 `3780` | + +## 非目标(本波) + +- 从默认安装器里摘掉 sidecar(Phase 5) +- 重写整套 Express → 独立 Node API +- 迁 openXYOS Chat +- 抽出 `packages/org-ui` monorepo(仅当 Dashboard Vite 图必须脱离时再做) diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index eb555377..a6b50add 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 Open-12 宿主 UI **已完成**(Chat 永久不迁,属明确非目标)。已落地:工作台总览、组织架构、员工目录、技能、治理 UI、知识、任务、反思、设置与智能体(`/organization/workspace` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `/organization/tasks` + `/organization/reflections` + `/organization/settings` + `/organization/agents` + `org-ui` WorkspacePage / OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage / TasksPage / ReflectionsPage / SettingsPage / AgentsPage + export-standalone 模块列表)。**下一步:Phase 4 导出与 IdentityBridge。** +**状态:** Phase 3 Open-12 宿主 UI **已完成**(Chat 永久不迁)。Phase 4 独立站导出 **已起步**:`freeos org export-standalone` 产出可运行 Vite + Docker 包(共享 `org-ui` + 本地 JWT IdentityBridge + 代理到宿主 `/api/org-module`)。自包含 org API 与默认安装器瘦身仍是 Phase 5。详见 [org-export.md](org-export.md)。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -26,8 +26,8 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 Open-12 宿主页 | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | | **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **已完成(Chat 除外)**:架构 / 员工 / 技能 / 治理 / 知识 / 任务 / 反思 / 设置 / 智能体 / 薄 Workspace overview 已迁。**Chat 永久不迁** | -| **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | -| **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | +| **4** 身份与导出 | 已迁页面走 IdentityBridge;`export-standalone` 产出可运行独立站(Vite SPA + 本地 JWT + 代理到宿主 org-module API) | **进行中**:可运行包已落地;自包含 Node API 仍是目标终态 | +| **5** 安装器瘦身 | 默认安装器去掉 sidecar / 保持零 Node;可选 `packages/org-ui` 抽取 | **未开始**(明确推迟;sidecar 仍可选) | Phase 0 已落地、Phase 1 **只冻结合同**。Phase 2 起才允许搬页面。 @@ -95,13 +95,11 @@ scripts/org-export/ # 独立 Vite + 最小 server,import 同 后续若导出必须脱离 Dashboard Vite 图,再升为 `packages/org-ui` + `packages/org-contract`。**不要**在 Phase 2 为了洁癖先拆包。 -命令草图(Phase 5,本文不实现): - ```bash uv run freeos org export-standalone --out dist/openxyos-web ``` -导出物是可部署的独立 Web(自带或可接客户的 openXYOS 后端),**不是** 再维护一份 `AnnouncementPage.tsx`。 +导出物是可部署的独立 Web(Vite SPA + Docker;过渡期代理到 FreeOS `/api/org-module`),**不是** 再维护一份 `AnnouncementPage.tsx`。客户说明见 [org-export.md](org-export.md)。 --- @@ -183,7 +181,7 @@ org-ui → IdentityBridge.getSession() 1. 壳无关组件:`dashboard/src/org-ui/pages/announcements`,数据经 `createOrgApiClient`。 2. Dashboard 子路由:`/organization/announcements`(工作台 path tabs + 入口卡)。 3. 宿主 API:`/api/org-module/announcements*`,SQLite 在 `{FREEOS_HOME}/org/announcements.sqlite`;嵌入模式打宿主 JWT,不打 `:3780`。 -4. 导出骨架:`freeos org export-standalone --out …` 写出共享模块列表 + 导入同一 `AnnouncementPage` 的 `App.tsx`(完整 Vite/Node 打包仍是 Phase 5 TODO)。 +4. 导出骨架:`freeos org export-standalone --out …` 写出共享模块列表 + 导入同一 `AnnouncementPage` 的 `App.tsx`。Phase 4 已把该骨架做成可运行 Vite/Docker 包(见下方)。 ### 验收标准 @@ -519,6 +517,28 @@ Phase 3 Open-12 宿主 UI 至此收口。下一步是 Phase 4 导出与 Identity --- +## Phase 4 进度:独立站导出(本波) + +按 ADR 003 把骨架导出做成 **客户能解开就跑** 的包。不搬安装器、不删 sidecar。 + +### 已落地 + +1. `freeos org export-standalone --out ` 复制 `scripts/org-export/template/` + `dashboard/src/org-ui`(不含测试)。 +2. 独立壳:Vite、OpenApp 风格路由、登录页、`createLocalJwtBridge`(`openxyos.standalone.jwt`,与 Dashboard `auth_token` 分离)。 +3. 过渡 API:同源 `/api`,由 Vite / `server/proxy.mjs` / nginx 反代到 `FREEOS_UPSTREAM`(FreeOS `/api/org-module/*` + `/api/auth/login`)。 +4. 部署物:`Dockerfile`、`docker-compose.yml`、导出目录 `README.md`、仓库 [org-export.md](org-export.md)。 +5. Chat 仍不导出;`/chat`、`/experts`、`/system-settings` 为深链说明页。 + +### 本阶段明确推迟(Phase 5) + +| 推迟项 | 原因 | +|---|---| +| 从默认安装器摘掉 sidecar | 用户明确本波不做;`FREEOS_ORG_SIDECAR` 仍可选 | +| 自包含 org-module Node/Fastify 服务 | 过渡期复用宿主 BFF;`server/proxy.mjs` 是占位 | +| `packages/org-ui` 抽包 | 导出已拷贝 org-ui;不必先拆 monorepo | + +--- + ## 明确非目标 | 非目标 | 说明 | @@ -537,6 +557,7 @@ Phase 3 Open-12 宿主 UI 至此收口。下一步是 Phase 4 导出与 Identity ## 相关文档 +- [独立站导出说明](org-export.md) - [ADR 003 — 单源双交付](adr/003-org-ui-single-source-dual-delivery.md) - [FreeOS × openXYOS 集成架构](architecture-integration.md) - [自增长 loop](asset-loop.md) diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index f2eae7e0..5fe08699 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -1,59 +1,45 @@ -# Organization standalone export (Phase 3 skeleton) +# Organization standalone export (Phase 4) -`freeos org export-standalone --out dist/openxyos-web` writes a scaffold that -**imports the same org-ui pages** Dashboard mounts under `/organization/...`. +`freeos org export-standalone --out dist/openxyos-web` copies this +`template/` plus `dashboard/src/org-ui` into a **runnable** Vite package. -Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, -**Governance**, **Knowledge**, **Tasks**, **Reflections**, **Settings**, -and **Agents**: +OpenApp-like routes: `/app`, `/announcements`, `/org`, `/employees`, +`/skills`, `/agents`, `/tasks`, `/knowledge`, `/reflections`, +`/governance`, `/settings`. Chat is **not** exported. | Delivery | Route | Component | |---|---|---| -| Dashboard | `/organization/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | -| Standalone (this export) | `/announcements` | same import | -| Dashboard | `/organization/org` | `dashboard/src/org-ui` → `OrgChartPage` | -| Standalone (this export) | `/org` | same import | -| Dashboard | `/organization/employees` | `dashboard/src/org-ui` → `EmployeesPage` | -| Dashboard | `/organization/employees/:id` | `dashboard/src/org-ui` → `EmployeeDetailPage` | -| Standalone (this export) | `/employees` | same import | -| Dashboard | `/organization/skills` | `dashboard/src/org-ui` → `SkillsPage` | -| Standalone (this export) | `/skills` | same import | -| Dashboard | `/organization/governance` | `dashboard/src/org-ui` → `GovernancePage` | -| Standalone (this export) | `/governance` | same import | -| Dashboard | `/organization/knowledge` | `dashboard/src/org-ui` → `KnowledgePage` | -| Standalone (this export) | `/knowledge` | same import | -| Dashboard | `/organization/tasks` | `dashboard/src/org-ui` → `TasksPage` | -| Dashboard | `/organization/tasks/:id` | `dashboard/src/org-ui` → `TaskDetailPage` | -| Standalone (this export) | `/tasks` | same import | -| Dashboard | `/organization/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | -| Standalone (this export) | `/reflections` | same import | -| Dashboard | `/organization/settings` | `dashboard/src/org-ui` → `SettingsPage` | -| Standalone (this export) | `/settings` | same import | -| Dashboard | `/organization/agents` | `dashboard/src/org-ui` → `AgentsPage` | -| Standalone (this export) | `/agents` | same import | +| Dashboard | `/organization/workspace` | `WorkspacePage` | +| Standalone | `/app` | same | +| Dashboard | `/organization/announcements` | `AnnouncementPage` | +| Standalone | `/announcements` | same | +| Dashboard | `/organization/org` | `OrgChartPage` | +| Standalone | `/org` | same | +| Dashboard | `/organization/employees` | `EmployeesPage` / `EmployeeDetailPage` | +| Standalone | `/employees` | same | +| Dashboard | `/organization/skills` | `SkillsPage` | +| Standalone | `/skills` | same | +| Dashboard | `/organization/agents` | `AgentsPage` | +| Standalone | `/agents` | same | +| Dashboard | `/organization/tasks` | `TasksPage` / `TaskDetailPage` | +| Standalone | `/tasks` | same | +| Dashboard | `/organization/knowledge` | `KnowledgePage` | +| Standalone | `/knowledge` | same | +| Dashboard | `/organization/reflections` | `ReflectionsPage` | +| Standalone | `/reflections` | same | +| Dashboard | `/organization/governance` | `GovernancePage` | +| Standalone | `/governance` | same | +| Dashboard | `/organization/settings` | `SettingsPage` | +| Standalone | `/settings` | same | -Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org -chart. Organization skills live under `{FREEOS_HOME}/org-skills/` (skill_bridge). -Governance pauses/audit live under `{FREEOS_HOME}/governance/`. -Organization Knowledge lists host FreeOS knowledge bases (same rows as -`/knowledge-bases`); it does not clone the sidecar notes/files DB. -Organization Tasks live in `{FREEOS_HOME}/org/tasks.sqlite` — not Octop cron -and not agent/project chat. -Organization Reflections live in `{FREEOS_HOME}/org/reflections.sqlite` — -lessons learned, not Chat and not a second skill runtime. -Organization Settings are org-module only: catalog toggles and -`{FREEOS_HOME}/org-os/prefs.json`. They do **not** duplicate FreeOS -system settings (LLM keys, users, timezone). -Organization Agents compile `openxyos.agent-blueprint.v1` into host -lifecycle colleagues. This is **not** the FreeOS personalization editor, -Chat runtime, or sidecar `/api/agent-studio/*`. -Do not copy the pages into a second tree. +Identity: standalone `createLocalJwtBridge` (`openxyos.standalone.jwt`), +not the embedded FreeOS Dashboard session. -## Phase 5 TODO +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 Vite + minimal server packaging -- Local JWT IdentityBridge for customer self-host -- Remaining Open-12 pages after they land in `org-ui` -- Optional `packages/org-ui` extraction if the Dashboard Vite graph must be left behind +## Phase 5 (deferred) -Default FreeOS installers stay **zero-Node**. This export is an explicit operator action. +- Slim the default installer (sidecar remains opt-in here) +- Optional `packages/org-ui` extraction if the Dashboard Vite graph must be left behind diff --git a/scripts/org-export/template/.dockerignore b/scripts/org-export/template/.dockerignore new file mode 100644 index 00000000..59cbe625 --- /dev/null +++ b/scripts/org-export/template/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +*.md +.env +.env.local diff --git a/scripts/org-export/template/.env.example b/scripts/org-export/template/.env.example new file mode 100644 index 00000000..5c0660c6 --- /dev/null +++ b/scripts/org-export/template/.env.example @@ -0,0 +1,9 @@ +# Same-origin API prefix used by the SPA (recommended). +VITE_API_BASE=/api + +# FreeOS host that owns /api/org-module/* and /api/auth/*. +# Used by `npm run dev`, `npm start`, and docker-compose. +FREEOS_UPSTREAM=http://127.0.0.1:8088 + +# Listen port for Vite / the Node static+proxy server. +# PORT=3780 diff --git a/scripts/org-export/template/.gitignore b/scripts/org-export/template/.gitignore new file mode 100644 index 00000000..225489c7 --- /dev/null +++ b/scripts/org-export/template/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +.env +.env.local +*.tsbuildinfo diff --git a/scripts/org-export/template/Dockerfile b/scripts/org-export/template/Dockerfile new file mode 100644 index 00000000..82b72047 --- /dev/null +++ b/scripts/org-export/template/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY nginx.conf.template /etc/nginx/templates/default.conf.template +COPY --from=build /app/dist /usr/share/nginx/html +ENV FREEOS_UPSTREAM=http://127.0.0.1:8088 +ENV NGINX_ENVSUBST_FILTER=FREEOS_UPSTREAM +EXPOSE 80 diff --git a/scripts/org-export/template/README.md b/scripts/org-export/template/README.md new file mode 100644 index 00000000..717b492c --- /dev/null +++ b/scripts/org-export/template/README.md @@ -0,0 +1,90 @@ +# Standalone Organization web (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. + +Chat is **not** migrated. `/chat` is a deep-link stub that points operators +back to FreeOS / Octop. + +## What you get + +| Module | Standalone route | Host API (interim) | +|---|---|---| +| workspace | `/app` | `GET /api/org-module/overview` | +| announcements | `/announcements` | `/api/org-module/announcements` | +| organization | `/org` | `/api/org-module/org` | +| employees | `/employees` | `/api/org-module/org/employees` | +| skills | `/skills` | `/api/org-module/skills` | +| agents | `/agents` | `/api/org-module/agents` | +| tasks | `/tasks` | `/api/org-module/tasks` | +| knowledge | `/knowledge` | `/api/org-module/knowledge` | +| reflections | `/reflections` | `/api/org-module/reflections` | +| governance | `/governance` | `/api/org-module/governance` | +| settings | `/settings` | `/api/org-module/settings` | + +Components: `AnnouncementPage`, `OrgChartPage`, `EmployeesPage`, +`EmployeeDetailPage`, `SkillsPage`, `GovernancePage`, `KnowledgePage`, +`TasksPage`, `TaskDetailPage`, `ReflectionsPage`, `SettingsPage`, +`AgentsPage`, `WorkspacePage`. + +## Auth (IdentityBridge) + +Standalone 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 `. + +## Interim vs target API + +**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/*`. + +**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. + +## Run locally + +```bash +cp .env.example .env # set FREEOS_UPSTREAM if FreeOS is not :8088 +npm install +npm run dev # Vite on :3780, proxies /api +``` + +Production-style (build + tiny Node static/proxy server): + +```bash +npm install +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. + +## Docker + +```bash +export FREEOS_UPSTREAM=http://host.docker.internal:8088 +docker compose up --build +``` + +`docker-compose.yml` publishes `:3780` and proxies `/api` to `FREEOS_UPSTREAM`. +On Linux the compose file adds `host-gateway` so `host.docker.internal` works. + +## Config + +| Variable | Where | Meaning | +|---|---|---| +| `VITE_API_BASE` | build-time | SPA API prefix. Default `/api` (same origin). | +| `FREEOS_UPSTREAM` | runtime | FreeOS origin that owns `/api/org-module` and `/api/auth`. | +| `PORT` | `npm start` / Vite | Listen port (default 3780). | + +## Not included + +- openXYOS Chat / a second agent runtime +- Default FreeOS installer changes (sidecar stays optional — Phase 5) +- Commercial `App.tsx` routes (contracts, attendance, …) diff --git a/scripts/org-export/template/docker-compose.yml b/scripts/org-export/template/docker-compose.yml new file mode 100644 index 00000000..cd214aee --- /dev/null +++ b/scripts/org-export/template/docker-compose.yml @@ -0,0 +1,12 @@ +# Interim commercial path: SPA + reverse proxy to a FreeOS host. +# Target end-state: this package ships a self-contained org-module API +# (no FreeOS process required). See README.md. +services: + openxyos-web: + build: . + ports: + - "3780:80" + environment: + FREEOS_UPSTREAM: ${FREEOS_UPSTREAM:-http://host.docker.internal:8088} + extra_hosts: + - "host.docker.internal:host-gateway" diff --git a/scripts/org-export/template/index.html b/scripts/org-export/template/index.html new file mode 100644 index 00000000..c7545fdc --- /dev/null +++ b/scripts/org-export/template/index.html @@ -0,0 +1,12 @@ + + + + + + openXYOS + + +
+ + + diff --git a/scripts/org-export/template/nginx.conf.template b/scripts/org-export/template/nginx.conf.template new file mode 100644 index 00000000..34c368d3 --- /dev/null +++ b/scripts/org-export/template/nginx.conf.template @@ -0,0 +1,21 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass ${FREEOS_UPSTREAM}/api/; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/scripts/org-export/template/package.json b/scripts/org-export/template/package.json new file mode 100644 index 00000000..01e0c0f1 --- /dev/null +++ b/scripts/org-export/template/package.json @@ -0,0 +1,28 @@ +{ + "name": "openxyos-web", + "private": true, + "version": "0.1.0", + "description": "Standalone Organization web generated from dashboard/src/org-ui. Interim API talks to a FreeOS host /api/org-module via proxy.", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview --host --port 3780", + "start": "node server/proxy.mjs" + }, + "dependencies": { + "antd": "^5.29.1", + "lucide-react": "^0.562.0", + "react": "^18", + "react-dom": "^18", + "react-router-dom": "^7.13.0" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "@types/react": "^18", + "@types/react-dom": "^18", + "@vitejs/plugin-react": "^4.4.1", + "typescript": "~5.8.3", + "vite": "^6.3.5" + } +} diff --git a/scripts/org-export/template/server/proxy.mjs b/scripts/org-export/template/server/proxy.mjs new file mode 100644 index 00000000..4a571731 --- /dev/null +++ b/scripts/org-export/template/server/proxy.mjs @@ -0,0 +1,100 @@ +/** + * Minimal static + /api reverse-proxy (interim BFF). + * Serves `dist/` and forwards /api/* to FREEOS_UPSTREAM (a FreeOS host). + * + * Target end-state: replace this file with a self-contained org-module + * server that implements /api/org-module/* and local JWT auth without + * requiring a FreeOS process. + */ +import fs from "node:fs"; +import http from "node:http"; +import https from "node:https"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PORT = Number(process.env.PORT || 3780); +const UPSTREAM = (process.env.FREEOS_UPSTREAM || "http://127.0.0.1:8088").replace( + /\/$/, + "", +); +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "dist"); + +const TYPES = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +function sendFile(res, filePath) { + const ext = path.extname(filePath); + res.writeHead(200, { "Content-Type": TYPES[ext] || "application/octet-stream" }); + fs.createReadStream(filePath).pipe(res); +} + +function spaFallback(res) { + const index = path.join(ROOT, "index.html"); + if (!fs.existsSync(index)) { + res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("dist/index.html missing — run npm run build first"); + return; + } + sendFile(res, index); +} + +function proxyApi(req, res) { + const target = new URL(req.url || "/", `${UPSTREAM}/`); + const headers = { ...req.headers, host: target.host }; + const client = target.protocol === "https:" ? https : http; + const upstream = client.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port, + method: req.method, + path: `${target.pathname}${target.search}`, + headers, + }, + (up) => { + res.writeHead(up.statusCode || 502, up.headers); + up.pipe(res); + }, + ); + upstream.on("error", (err) => { + res.writeHead(502, { "Content-Type": "application/json; charset=utf-8" }); + res.end( + JSON.stringify({ + error: "upstream_unreachable", + message: `Cannot reach FreeOS at ${UPSTREAM}: ${err.message}`, + }), + ); + }); + req.pipe(upstream); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url || "/", "http://localhost"); + if (url.pathname === "/api" || url.pathname.startsWith("/api/")) { + proxyApi(req, res); + return; + } + const rel = decodeURIComponent(url.pathname).replace(/^\/+/, ""); + const filePath = path.normalize(path.join(ROOT, rel || "index.html")); + if (!filePath.startsWith(ROOT)) { + res.writeHead(400); + res.end(); + return; + } + if (rel && fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + sendFile(res, filePath); + return; + } + spaFallback(res); +}); + +server.listen(PORT, "0.0.0.0", () => { + console.log(`openxyos-web listening on :${PORT} (API → ${UPSTREAM})`); +}); diff --git a/scripts/org-export/template/src/App.tsx b/scripts/org-export/template/src/App.tsx new file mode 100644 index 00000000..5c530809 --- /dev/null +++ b/scripts/org-export/template/src/App.tsx @@ -0,0 +1,327 @@ +import { useMemo, useState } from "react"; +import { Navigate, Route, Routes, useNavigate, useParams } from "react-router-dom"; +import { + AgentsPage, + AnnouncementPage, + EmployeeDetailPage, + EmployeesPage, + GovernancePage, + KnowledgePage, + OrgChartPage, + ReflectionsPage, + SHARED_ORG_UI_MODULES, + SettingsPage, + SkillsPage, + TaskDetailPage, + TasksPage, + WorkspacePage, + createBridgeFetcher, + createLocalJwtBridge, + createOrgApiClient, + StandaloneUnauthorizedError, + type OrgEmployeesClient, + type OrgSession, + type OrgTasksClient, +} from "org-ui"; +import { LoginPage } from "./auth/LoginPage"; +import { HostDeepLinkPage } from "./shell/HostDeepLinkPage"; +import { StandaloneNav } from "./shell/Nav"; + +const API_BASE = (import.meta.env.VITE_API_BASE || "/api").replace(/\/$/, ""); + +const STANDALONE_LINKS = { + workbench: "/app", + announcements: "/announcements", + organization: "/org", + employees: "/employees", + skills: "/skills", + agents: "/agents", + tasks: "/tasks", + knowledge: "/knowledge", + reflections: "/reflections", + governance: "/governance", + settings: "/settings", + chat: "/chat", +}; + +function EmployeeRoute(props: { + client: OrgEmployeesClient; + session: OrgSession; + locale: "zh" | "en"; +}) { + const { id } = useParams(); + const navigate = useNavigate(); + return ( + navigate("/employees")} + modules={SHARED_ORG_UI_MODULES} + /> + ); +} + +function TaskRoute(props: { + client: OrgTasksClient; + session: OrgSession; + locale: "zh" | "en"; +}) { + const { id } = useParams(); + const navigate = useNavigate(); + return ( + navigate("/tasks")} + modules={SHARED_ORG_UI_MODULES} + /> + ); +} + +/** + * Runnable standalone shell. IdentityBridge is local JWT + * (`openxyos.standalone.jwt`), distinct from the FreeOS Dashboard session. + * Pages are the same org-ui components Dashboard mounts under /organization/... + */ +export default function App() { + const navigate = useNavigate(); + const locale: "zh" | "en" = + typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("zh") + ? "zh" + : "en"; + const [epoch, setEpoch] = useState(0); + const bridge = useMemo( + () => + createLocalJwtBridge({ + apiBase: API_BASE, + locale, + navigate: (path) => navigate(path), + }), + [locale, navigate], + ); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: async (path, init) => { + try { + return await createBridgeFetcher(bridge)(path, init); + } catch (err) { + if (err instanceof StandaloneUnauthorizedError) { + bridge.clearSession(); + setEpoch((n) => n + 1); + navigate("/login"); + } + throw err; + } + }, + }), + [bridge, navigate], + ); + void epoch; + const session = bridge.getSession(); + const signedIn = bridge.hasToken(); + + if (!signedIn) { + return ( + + { + setEpoch((n) => n + 1); + navigate("/app"); + }} + /> + } + /> + } /> + + ); + } + + return ( + <> + { + bridge.clearSession(); + setEpoch((n) => n + 1); + navigate("/login"); + }} + /> + + } /> + } /> + } /> + + } + /> + + } + /> + + } + /> + navigate(`/employees/${id}`)} + /> + } + /> + + } + /> + + } + /> + + } + /> + navigate(`/tasks/${id}`)} + /> + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + + + ); +} diff --git a/scripts/org-export/template/src/auth/LoginPage.module.css b/scripts/org-export/template/src/auth/LoginPage.module.css new file mode 100644 index 00000000..63026d68 --- /dev/null +++ b/scripts/org-export/template/src/auth/LoginPage.module.css @@ -0,0 +1,32 @@ +.page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.card { + width: min(420px, 100%); + background: #fff; + border-radius: 12px; + padding: 28px 24px 24px; + box-shadow: 0 8px 32px rgb(0 0 0 / 8%); +} + +.title { + margin: 0 0 8px !important; +} + +.hint { + margin: 0 0 20px; + color: #666; + font-size: 13px; + line-height: 1.5; +} + +.error { + color: #cf1322; + margin: 0 0 12px; + font-size: 13px; +} diff --git a/scripts/org-export/template/src/auth/LoginPage.tsx b/scripts/org-export/template/src/auth/LoginPage.tsx new file mode 100644 index 00000000..3c0f6627 --- /dev/null +++ b/scripts/org-export/template/src/auth/LoginPage.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { Button, Form, Input, Typography } from "antd"; +import { sessionFromLoginUser, type LocalJwtBridge } from "org-ui"; +import styles from "./LoginPage.module.css"; + +export function LoginPage(props: { + bridge: LocalJwtBridge; + locale: "zh" | "en"; + onSignedIn: () => void; +}) { + const zh = props.locale === "zh"; + const [error, setError] = useState(""); + const [pending, setPending] = useState(false); + + const submit = async (values: { username: string; password: string }) => { + setPending(true); + setError(""); + try { + const response = await fetch(`${props.bridge.apiBase()}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + const body = (await response.json().catch(() => ({}))) as { + access_token?: string; + user?: { + id?: number; + username?: string; + display_name?: string; + role?: string; + }; + message?: string; + error?: string; + }; + if (!response.ok || !body.access_token) { + throw new Error( + body.message || + body.error || + (zh ? "登录失败" : "Sign-in failed"), + ); + } + props.bridge.setSession( + body.access_token, + sessionFromLoginUser(body.user || { username: values.username }), + ); + props.onSignedIn(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setPending(false); + } + }; + + return ( +
+
+ + openXYOS + +

+ {zh + ? "独立站使用本地 JWT(openxyos.standalone.jwt),不是 Dashboard 嵌入会话。" + : "Standalone local JWT (openxyos.standalone.jwt), not the embedded Dashboard session."} +

+
+ + + + + + + {error ?

{error}

: null} + +
+
+
+ ); +} diff --git a/scripts/org-export/template/src/main.tsx b/scripts/org-export/template/src/main.tsx new file mode 100644 index 00000000..a95b4427 --- /dev/null +++ b/scripts/org-export/template/src/main.tsx @@ -0,0 +1,20 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import enUS from "antd/locale/en_US"; +import App from "./App"; +import "./styles.css"; + +const locale = navigator.language.toLowerCase().startsWith("zh") ? zhCN : enUS; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/scripts/org-export/template/src/shell/HostDeepLinkPage.module.css b/scripts/org-export/template/src/shell/HostDeepLinkPage.module.css new file mode 100644 index 00000000..80640cfe --- /dev/null +++ b/scripts/org-export/template/src/shell/HostDeepLinkPage.module.css @@ -0,0 +1,14 @@ +.page { + max-width: 640px; + margin: 0 auto; + padding: 32px 16px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.body { + margin: 0; + color: #555; + line-height: 1.6; +} diff --git a/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx b/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx new file mode 100644 index 00000000..2ebc8093 --- /dev/null +++ b/scripts/org-export/template/src/shell/HostDeepLinkPage.tsx @@ -0,0 +1,55 @@ +import { Button, Typography } from "antd"; +import { useNavigate } from "react-router-dom"; +import styles from "./HostDeepLinkPage.module.css"; + +const COPY: Record< + string, + { title: { zh: string; en: string }; body: { zh: string; en: string } } +> = { + chat: { + title: { zh: "沟通协作未迁入独立站", en: "Chat is not in this package" }, + 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.", + }, + }, + experts: { + title: { zh: "专家目录在宿主", en: "Experts live on the host" }, + body: { + zh: "注册后的数字同事出现在 FreeOS Experts。独立站只编译 / 流转同事,不编辑宿主智能体。", + en: "Spawned colleagues appear on FreeOS Experts. This site only compiles / transitions colleagues.", + }, + }, + personalization: { + title: { zh: "个性化编辑器在宿主", en: "Personalization stays on the host" }, + body: { + zh: "FreeOS 个性化智能体编辑器不在本导出包内。", + en: "The FreeOS personalization editor is not part of this export.", + }, + }, + "system-settings": { + title: { zh: "系统设置在宿主", en: "System settings stay on the host" }, + body: { + zh: "大模型密钥、用户、时区仍在 FreeOS /system-settings。本站设置页只改组织模块开关与本地偏好。", + en: "LLM keys, users, and timezone stay on FreeOS /system-settings. This site only edits org-module toggles and prefs.", + }, + }, +}; + +export function HostDeepLinkPage(props: { + kind: keyof typeof COPY; + locale: "zh" | "en"; +}) { + const navigate = useNavigate(); + const copy = COPY[props.kind]; + const locale = props.locale; + return ( +
+ {copy.title[locale]} +

{copy.body[locale]}

+ +
+ ); +} diff --git a/scripts/org-export/template/src/shell/Nav.module.css b/scripts/org-export/template/src/shell/Nav.module.css new file mode 100644 index 00000000..ded2ff70 --- /dev/null +++ b/scripts/org-export/template/src/shell/Nav.module.css @@ -0,0 +1,40 @@ +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; + padding: 10px 16px; + background: #fff; + border-bottom: 1px solid #eee; +} + +.brand { + font-size: 15px; +} + +.links { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + flex: 1; +} + +.link { + text-decoration: none; + color: #555; + font-size: 13px; + padding: 2px 0; +} + +.active { + color: #1677ff; + font-weight: 600; +} + +.user { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #666; +} diff --git a/scripts/org-export/template/src/shell/Nav.tsx b/scripts/org-export/template/src/shell/Nav.tsx new file mode 100644 index 00000000..eb216d25 --- /dev/null +++ b/scripts/org-export/template/src/shell/Nav.tsx @@ -0,0 +1,51 @@ +import { Button } from "antd"; +import { NavLink } from "react-router-dom"; +import type { LocalJwtBridge } from "org-ui"; +import styles from "./Nav.module.css"; + +const LINKS: { to: string; en: string; zh: string }[] = [ + { to: "/app", en: "Workspace", zh: "工作台" }, + { to: "/announcements", en: "Announcements", zh: "公告" }, + { to: "/org", en: "Org", zh: "架构" }, + { to: "/employees", en: "Employees", zh: "员工" }, + { to: "/skills", en: "Skills", zh: "技能" }, + { to: "/agents", en: "Agents", zh: "智能体" }, + { to: "/tasks", en: "Tasks", zh: "任务" }, + { to: "/knowledge", en: "Knowledge", zh: "知识" }, + { to: "/reflections", en: "Reflections", zh: "反思" }, + { to: "/governance", en: "Governance", zh: "治理" }, + { to: "/settings", en: "Settings", zh: "设置" }, +]; + +export function StandaloneNav(props: { + bridge: LocalJwtBridge; + locale: "zh" | "en"; + onSignOut: () => void; +}) { + const session = props.bridge.getSession(); + const zh = props.locale === "zh"; + return ( +
+ openXYOS + +
+ {session.displayName || session.role} + +
+
+ ); +} diff --git a/scripts/org-export/template/src/styles.css b/scripts/org-export/template/src/styles.css new file mode 100644 index 00000000..9f6d39b4 --- /dev/null +++ b/scripts/org-export/template/src/styles.css @@ -0,0 +1,24 @@ +:root { + color: #1f1f1f; + background: #f5f5f5; + font-family: + "Segoe UI", + system-ui, + -apple-system, + sans-serif; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + margin: 0; + min-height: 100%; +} + +a { + color: inherit; +} diff --git a/scripts/org-export/template/src/vite-env.d.ts b/scripts/org-export/template/src/vite-env.d.ts new file mode 100644 index 00000000..7a7f1cd9 --- /dev/null +++ b/scripts/org-export/template/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/scripts/org-export/template/tsconfig.app.json b/scripts/org-export/template/tsconfig.app.json new file mode 100644 index 00000000..bdcd7193 --- /dev/null +++ b/scripts/org-export/template/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "org-ui": ["src/org-ui"], + "org-ui/*": ["src/org-ui/*"] + } + }, + "include": ["src"] +} diff --git a/scripts/org-export/template/tsconfig.json b/scripts/org-export/template/tsconfig.json new file mode 100644 index 00000000..d32ff682 --- /dev/null +++ b/scripts/org-export/template/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/scripts/org-export/template/tsconfig.node.json b/scripts/org-export/template/tsconfig.node.json new file mode 100644 index 00000000..e7c81406 --- /dev/null +++ b/scripts/org-export/template/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["node"] + }, + "include": ["vite.config.ts"] +} diff --git a/scripts/org-export/template/vite.config.ts b/scripts/org-export/template/vite.config.ts new file mode 100644 index 00000000..5cda7026 --- /dev/null +++ b/scripts/org-export/template/vite.config.ts @@ -0,0 +1,47 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const upstream = + env.FREEOS_UPSTREAM || process.env.FREEOS_UPSTREAM || "http://127.0.0.1:8088"; + + return { + plugins: [react()], + resolve: { + alias: { + "org-ui": path.resolve(here, "src/org-ui"), + }, + dedupe: ["react", "react-dom"], + }, + css: { + modules: { + localsConvention: "camelCase", + }, + }, + server: { + host: "0.0.0.0", + port: Number(process.env.PORT || 3780), + proxy: { + "/api": { + target: upstream, + changeOrigin: true, + }, + }, + }, + preview: { + host: "0.0.0.0", + port: Number(process.env.PORT || 3780), + proxy: { + "/api": { + target: upstream, + changeOrigin: true, + }, + }, + }, + }; +}); diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 512a7ab3..a50a7c54 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -379,16 +379,14 @@ def assets_apply(pack_dir: Path | None, tenant_id: str, base_url: str) -> None: "out_dir", type=click.Path(path_type=Path), required=True, - help="Output directory for the standalone web scaffold.", + help="Output directory for the standalone Organization web package.", ) def export_standalone(out_dir: Path) -> None: - """Scaffold a standalone Organization web from shared org-ui (Phase 3). + """Export a runnable standalone Organization web from shared org-ui. - Writes the shared module list and an App.tsx that imports AnnouncementPage, - OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, KnowledgePage, - TasksPage, ReflectionsPage, SettingsPage, AgentsPage, and WorkspacePage. - Chat is not exported. Full Vite + Node packaging is Phase 5 - (TODO in the generated README). + Copies dashboard/src/org-ui plus a Vite + Docker shell (local JWT + IdentityBridge, /api proxy to a FreeOS host). Chat is not exported. + Sidecar stays in the default installer (Phase 5). """ from octop.modules.org_os.export_standalone import write_standalone_scaffold diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index b64eeeea..f593250d 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -1,291 +1,58 @@ -"""Minimal ``freeos org export-standalone`` scaffold (Phase 3). - -Full Vite + Node server packaging is Phase 5. This writes a snapshot that -lists the shared org-ui modules and points at the same AnnouncementPage, -OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, KnowledgePage, -TasksPage, ReflectionsPage, SettingsPage, AgentsPage, and WorkspacePage -sources Dashboard mounts under ``/organization/...``. -""" +"""``freeos org export-standalone`` — runnable Vite package from org-ui.""" from __future__ import annotations import json +import shutil from pathlib import Path from typing import Any from octop.modules.org_os.contract import SHARED_ORG_UI_MODULES -_README = """# Standalone Organization web (export skeleton) - -This directory is produced by `freeos org export-standalone`. - -## Shared UI (single source) - -Dashboard and this export consume the **same** org-ui pages: - -| Module | Dashboard route | Standalone route | Import | -|---|---|---|---| -| announcements | `/organization/announcements` | `/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | -| organization | `/organization/org` | `/org` | `dashboard/src/org-ui` → `OrgChartPage` | -| employees | `/organization/employees` | `/employees` | `dashboard/src/org-ui` → `EmployeesPage` / `EmployeeDetailPage` | -| skills | `/organization/skills` | `/skills` | `dashboard/src/org-ui` → `SkillsPage` | -| governance | `/organization/governance` | `/governance` | `dashboard/src/org-ui` → `GovernancePage` | -| knowledge | `/organization/knowledge` | `/knowledge` | `dashboard/src/org-ui` → `KnowledgePage` | -| tasks | `/organization/tasks` | `/tasks` | `dashboard/src/org-ui` → `TasksPage` / `TaskDetailPage` | -| reflections | `/organization/reflections` | `/reflections` | `dashboard/src/org-ui` → `ReflectionsPage` | -| settings | `/organization/settings` | `/settings` | `dashboard/src/org-ui` → `SettingsPage` | -| agents | `/organization/agents` | `/agents` | `dashboard/src/org-ui` → `AgentsPage` | -| workspace | `/organization/workspace` | `/app` | `dashboard/src/org-ui` → `WorkspacePage` | - -Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. - -Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org -chart. Host lifecycle colleagues stay on `GET /api/org-module/employees`. -Governance pauses and audit stay on `{FREEOS_HOME}/governance/`. -Organization skills stay on `{FREEOS_HOME}/org-skills/` (skill_bridge). -Host Agent Skills remain FreeOS skill packages — not a second runtime. -Organization Knowledge lists the same FreeOS knowledge bases Chat retrieves -from. It does not clone the openXYOS sidecar notes/files DB. -Organization Tasks live in `{FREEOS_HOME}/org/tasks.sqlite`. They are **not** -Octop cron jobs and **not** agent/project chat. -Organization Reflections live in `{FREEOS_HOME}/org/reflections.sqlite`. -They are lessons learned, not Chat and not a second skill runtime. -Organization Settings are **org-module only**: catalog toggles -(`{FREEOS_HOME}/org-os/module-toggles.json`) and org-local prefs -(`{FREEOS_HOME}/org-os/prefs.json`). LLM keys, users, timezone, and -models stay on FreeOS system settings (`/system-settings`). -Organization Agents compile `openxyos.agent-blueprint.v1` into host -lifecycle colleagues (`{FREEOS_HOME}/tenants//employees/`). This is -**not** the FreeOS personalization editor, Chat runtime, or sidecar -`/api/agent-studio/*`. Spawned colleagues appear on FreeOS Experts. -Organization Workspace is a thin OpenDashboard landing page. It reads -`GET /api/org-module/overview` and links into already-migrated pages. -It is **not** a second control plane (assemble / pack / loop stay on -the Organization workbench). Chat is **not** migrated. - -## Phase 3 vs Phase 5 - -Phase 3 (this scaffold): - -- Shared module list (`src/modules.json`) -- Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, - `EmployeesPage`, `SkillsPage`, `GovernancePage`, `KnowledgePage`, - `TasksPage`, `ReflectionsPage`, `SettingsPage`, `AgentsPage`, - and `WorkspacePage` -- Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT - -Phase 5 (TODO — not implemented here): - -- Full Vite + minimal server packaging -- Commercial `App.tsx` routes -- Independent `packages/org-ui` extraction if the Dashboard Vite graph must be left behind -- Default installer stays zero-Node; this export is an explicit operator action - -## Next - -```bash -# from a FreeOS checkout -uv run freeos org export-standalone --out dist/openxyos-web -# then (Phase 5) npm install && npm run build inside the out dir -``` -""" - -_APP_TSX = """\ -import { - AgentsPage, - AnnouncementPage, - EmployeeDetailPage, - EmployeesPage, - GovernancePage, - KnowledgePage, - OrgChartPage, - ReflectionsPage, - SHARED_ORG_UI_MODULES, - SettingsPage, - SkillsPage, - TaskDetailPage, - TasksPage, - WorkspacePage, -} from "org-ui"; -import type { - OrgAgentsClient, - OrgAnnouncementsClient, - OrgChartClient, - OrgEmployeesClient, - OrgGovernanceClient, - OrgKnowledgeClient, - OrgReflectionsClient, - OrgSession, - OrgSettingsClient, - OrgSkillsClient, - OrgTasksClient, - OrgWorkspaceClient, -} from "org-ui"; - -/** - * Standalone shell stub. Phase 5 wires a real IdentityBridge + local JWT. - * Page components are the same ones Dashboard mounts at - * /organization/announcements, /organization/org, /organization/employees, - * /organization/skills, /organization/governance, /organization/knowledge, - * /organization/tasks, /organization/reflections, - * /organization/settings, /organization/agents, and - * /organization/workspace. - */ -const session: OrgSession = { - userId: 0, - displayName: "standalone", - role: "admin", - isAdmin: true, -}; - -export default function App(props: { - client: OrgAnnouncementsClient; - orgClient: OrgChartClient; - employeesClient: OrgEmployeesClient; - skillsClient: OrgSkillsClient; - governanceClient: OrgGovernanceClient; - knowledgeClient: OrgKnowledgeClient; - tasksClient: OrgTasksClient; - reflectionsClient: OrgReflectionsClient; - settingsClient: OrgSettingsClient; - agentsClient: OrgAgentsClient; - workspaceClient: OrgWorkspaceClient; - locale?: "zh" | "en"; -}) { - return ( - <> - - - - - - - - - - - - - - - ); -} -""" - -_PACKAGE_JSON = { - "name": "openxyos-web", - "private": True, - "version": "0.0.0", - "description": ( - "Standalone Organization web export skeleton. " - "Pages come from dashboard/src/org-ui " - "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge + Tasks + Reflections + Settings + Agents + Workspace). " - "Full Node packaging is Phase 5." - ), - "type": "module", - "scripts": { - "dev": "echo 'TODO Phase 5: Vite + server packaging'", - "build": "echo 'TODO Phase 5: Vite + server packaging'", - }, - "peerDependencies": { - "antd": "^5", - "react": "^18", - "react-dom": "^18", - }, -} - - -def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: - dest = Path(out_dir) - dest.mkdir(parents=True, exist_ok=True) - src = dest / "src" - src.mkdir(parents=True, exist_ok=True) - - modules = { +_ORG_UI_IGNORE = ( + "*.test.ts", + "*.test.tsx", + "*.spec.ts", + "*.spec.tsx", + "__snapshots__", + ".DS_Store", +) + + +def _repo_root() -> Path: + here = Path(__file__).resolve() + packaged = here.parents[4] + if _looks_like_checkout(packaged): + return packaged + cwd = Path.cwd() + if _looks_like_checkout(cwd): + return cwd + raise FileNotFoundError( + "freeos org export-standalone needs a FreeOS source checkout " + "(dashboard/src/org-ui and scripts/org-export/template). " + "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() + + +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", @@ -370,15 +137,49 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "not_migrated": ["chat"], }, }, - "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", + "not_exported": ["chat"], + "todo": "Phase 5: slim default installer (sidecar stays opt-in); self-contained org API", } - (dest / "README.md").write_text(_README, encoding="utf-8") - (dest / "package.json").write_text(json.dumps(_PACKAGE_JSON, indent=2) + "\n", encoding="utf-8") - (src / "modules.json").write_text(json.dumps(modules, indent=2) + "\n", encoding="utf-8") - (src / "App.tsx").write_text(_APP_TSX, encoding="utf-8") + + +def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: + dest = Path(out_dir) + dest.mkdir(parents=True, exist_ok=True) + root = _repo_root() + 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")) + shutil.copytree( + org_ui, + dest / "src" / "org-ui", + 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", + ) + 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", + ] return { "out_dir": str(dest), "modules": list(SHARED_ORG_UI_MODULES), - "files": ["README.md", "package.json", "src/modules.json", "src/App.tsx"], - "todo": "Phase 5: full Vite + server packaging", + "files": files, + "auth": "standalone local JWT (openxyos.standalone.jwt)", + "api": "proxies /api to FREEOS_UPSTREAM (interim); self-contained server is the target end-state", + "todo": "Phase 5: slim default installer (sidecar remains opt-in)", } diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 318b553e..8a2a59f4 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 the shared org-ui seam.""" +"""``freeos org export-standalone`` writes a runnable org-ui package.""" from __future__ import annotations @@ -10,12 +10,16 @@ from octop.cli.main import cli -def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: +def _export(tmp_path: Path) -> tuple[Path, dict[str, object]]: out = tmp_path / "openxyos-web" runner = CliRunner() result = runner.invoke(cli, ["org", "export-standalone", "--out", str(out)]) assert result.exit_code == 0, result.output - payload = json.loads(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", @@ -29,6 +33,8 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "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", @@ -43,6 +49,8 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "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" @@ -97,9 +105,11 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "SettingsPage" in app assert "AgentsPage" in app assert "WorkspacePage" in app + assert "createLocalJwtBridge" 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 "Phase 5" in readme assert "AnnouncementPage" in readme assert "OrgChartPage" in readme assert "EmployeesPage" in readme @@ -112,3 +122,38 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: 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 + + +def test_export_standalone_is_runnable_vite_package(tmp_path: Path) -> None: + out, _payload = _export(tmp_path) + 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 "FREEOS_UPSTREAM" in vite + assert (out / "Dockerfile").is_file() + compose = (out / "docker-compose.yml").read_text(encoding="utf-8") + assert "FREEOS_UPSTREAM" in compose + assert "3780:80" in compose + nginx = (out / "nginx.conf.template").read_text(encoding="utf-8") + 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