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 (
+
+ );
+}
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