diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 955ccdd..23a3804 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,5 +20,6 @@ jobs: node-version: ${{ matrix.node }} registry-url: https://registry.npmjs.org package-manager-cache: false + - run: npm ci - run: npm test - run: npm pack --dry-run diff --git a/README.md b/README.md index f7a4108..5c11dc3 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,27 @@ Multi-app theming CLI and runtime for supported Chromium/Electron desktop applic [中文文档](./README_zh.md) ```bash -npx --yes --package=@codedrobe/core@0.1.1 codedrobe apps -npx --yes --package=@codedrobe/core@0.1.1 codedrobe detect -npx --yes --package=@codedrobe/core@0.1.1 codedrobe apply --app workbuddy --theme /absolute/theme.codedrobe-theme +npx --yes --package=@codedrobe/core@0.2.0 codedrobe apps +npx --yes --package=@codedrobe/core@0.2.0 codedrobe detect +npx --yes --package=@codedrobe/core@0.2.0 codedrobe apply --app workbuddy --theme /absolute/theme.codedrobe-theme ``` Bun is supported as a CLI runtime: ```bash -bunx --package @codedrobe/core@0.1.1 codedrobe apps +bunx --package @codedrobe/core@0.2.0 codedrobe apps ``` +Check for or install the latest global CLI version: + +```bash +codedrobe update --check +codedrobe update +codedrobe update --check --json +``` + +Interactive global installations check at most once every 24 hours and print an update hint only when a newer version is available. JSON output, CI, non-interactive runs, and pinned `npx`/`bunx` invocations stay silent. Set `NO_UPDATE_NOTIFIER=1` or `CODEDROBE_DISABLE_UPDATE_CHECK=1` to disable automatic checks. + Applications can consume the same package as an ES module: ```bash @@ -26,4 +36,69 @@ npm install @codedrobe/core import { getAdapter, launchApp, readThemePackage } from "@codedrobe/core"; ``` +The package ships TypeScript declarations for the root API and the `@codedrobe/core/adapters` and `@codedrobe/core/theme` subpath exports. + Built-in adapters currently include `codex` and `workbuddy`. Existing applications are never restarted unless `--restart-existing` is explicitly provided. + +If an application is installed outside the adapter's default locations, pass its app bundle, installation directory, or executable file explicitly: + +```bash +codedrobe detect --app workbuddy --app-path "/custom/WorkBuddy.app" +codedrobe apply --app workbuddy --app-path "/custom/WorkBuddy.app" --theme /absolute/theme.codedrobe-theme +``` + +The same `appPath` override is available to applications using the exported `launchApp()` API. + +Probe an application's current DOM without injecting or removing a theme: + +```bash +codedrobe probe --app codex +codedrobe probe --app codex --theme /absolute/theme.codedrobe-theme --json +codedrobe probe --app workbuddy --timeout-ms 10000 +``` + +`probe` never launches or restarts an application. It prints the target address immediately, waits 5 seconds by default, and suggests the matching `codedrobe launch` command when no CDP renderer is available. Use `--timeout-ms` to change the wait from 250ms up to 5 minutes. + +CodeDrobe probes each adapter's stable cross-route renderer landmarks before injecting CSS. A theme may add required landmarks, advisory landmarks, and route-specific contexts without adding runtime JavaScript: + +```json +{ + "targets": { + "workbuddy": { + "css": "workbuddy.css", + "verification": { + "required": [ + { "name": "chat-surface", "any": [".chat-container", ".wb-cb-chat"] } + ], + "recommended": [ + { "name": "conversation-list", "any": [".conversation-list"] } + ], + "contexts": [ + { + "name": "active-chat", + "when": { "any": [".chat-route"] }, + "required": [ + { "name": "message-list", "any": [".message-list"] } + ] + } + ] + } + } + } +} +``` + +Missing adapter or theme `required` landmarks stop injection and report the scope, active context, name, attempted selectors, and selector parse errors. Missing `recommended` landmarks are returned as warnings without blocking injection. Context checks run only while one of their `when.any` landmarks is visible. + +`codedrobe theme pack` and `codedrobe theme inspect` also report non-blocking selector lint warnings for positional selectors, deep direct-child chains, localized text attributes, generated classes, and unusually long selectors. Keep adapter files limited to stable cross-route landmarks; put feature- and theme-specific DOM dependencies in the theme target. + +Convert a legacy Codex-only package without copying JavaScript into a skill: + +```bash +codedrobe theme convert ./legacy.codex-theme \ + --output ./legacy.codedrobe-theme +``` + +The converter validates the old package, preserves its CSS, metadata, copy, artwork, and base-theme options, and creates a declaration-only package with a single `targets.codex` entry. Existing output files require `--force` before they can be replaced. + +The Codex adapter was last verified against macOS app version `26.707.72221` (build `5307`) and the WorkBuddy adapter against macOS `5.2.6`, both on 2026-07-16. WorkBuddy passed the full launch/probe/apply/verify/screenshot/restore flow. Use `codedrobe apps --json` to read this metadata. diff --git a/README_zh.md b/README_zh.md index 985229c..68394a6 100644 --- a/README_zh.md +++ b/README_zh.md @@ -22,8 +22,8 @@ codedrobe apps 无需全局安装: ```bash -npx --yes --package=@codedrobe/core@0.1.1 codedrobe apps -bunx --package @codedrobe/core@0.1.1 codedrobe apps +npx --yes --package=@codedrobe/core@0.2.0 codedrobe apps +bunx --package @codedrobe/core@0.2.0 codedrobe apps ``` 在源码仓库中也可以直接通过 Bun 运行: @@ -32,6 +32,16 @@ bunx --package @codedrobe/core@0.1.1 codedrobe apps bun ./bin/codedrobe.mjs apps ``` +检查和安装全局 CLI 的新版本: + +```bash +codedrobe update --check +codedrobe update +codedrobe update --check --json +``` + +`codedrobe update` 会根据当前运行环境选择 npm、Bun 或 pnpm,安装 `@codedrobe/core@latest` 的全局版本。普通命令只会在全局安装、交互式终端中检查更新;检查结果缓存 24 小时,网络失败不会影响原命令。`--json`、CI、非交互执行和固定版本的 `npx`/`bunx` 调用不会出现升级提示。可以设置 `NO_UPDATE_NOTIFIER=1` 或 `CODEDROBE_DISABLE_UPDATE_CHECK=1` 关闭自动检查。 + Skill 应固定 `codedrobe` 的准确版本,避免 `npx` 自动取得新版本后行为漂移。CodeDrobe Desktop 等软件则把它作为普通依赖,在 Electron 主进程使用导出的 API: ```bash @@ -42,6 +52,8 @@ npm install @codedrobe/core import { getAdapter, launchApp, readThemePackage } from "@codedrobe/core"; ``` +npm 包内置 TypeScript 类型声明,覆盖根入口以及 `@codedrobe/core/adapters`、`@codedrobe/core/theme` 子路径,不需要另外安装 `@types` 包。 + Skill 目录不再复制 JavaScript 运行时,只保留 `SKILL.md`、必要 references 和对 `@codedrobe/core@固定版本` 的调用说明。 本仓库开发时可以链接命令: @@ -60,6 +72,28 @@ codedrobe apply \ --theme /absolute/dream-1.0.0.codedrobe-theme ``` +如果应用不在适配器的默认安装位置,可以通过 `--app-path` 传入应用目录、macOS `.app` 包或可执行文件: + +```bash +codedrobe detect --app workbuddy --app-path "/custom/WorkBuddy.app" +codedrobe apply \ + --app workbuddy \ + --app-path "/custom/WorkBuddy.app" \ + --theme /absolute/dream-1.0.0.codedrobe-theme +``` + +软件直接使用 `launchApp()` API 时,也可以传入同名的 `appPath` 字段。 + +只检查当前应用 DOM,不注入或移除主题: + +```bash +codedrobe probe --app codex +codedrobe probe --app codex --theme /absolute/theme.codedrobe-theme --json +codedrobe probe --app workbuddy --timeout-ms 10000 +``` + +`probe` 不会启动或重启应用。命令会立即显示正在检查的地址,默认等待 5 秒;找不到 CDP renderer 时会提示对应的 `codedrobe launch` 命令。可以使用 `--timeout-ms` 将等待时间设置为 250 毫秒至 5 分钟。 + 如果应用已经在未开启 CDP 的状态下运行,命令会停止并要求用户自行关闭应用,或者显式传入: ```bash @@ -98,13 +132,46 @@ codedrobe restore --app workbuddy }, "targets": { "codex": { "css": "/* Codex CSS */" }, - "workbuddy": { "css": "/* WorkBuddy CSS */" } + "workbuddy": { + "css": "/* WorkBuddy CSS */", + "verification": { + "required": [ + { + "name": "chat-surface", + "any": [".chat-container", ".wb-cb-chat"] + } + ], + "recommended": [ + { + "name": "conversation-list", + "any": [".conversation-list"] + } + ], + "contexts": [ + { + "name": "active-chat", + "when": { "any": [".chat-route"] }, + "required": [ + { "name": "message-list", "any": [".message-list"] } + ] + } + ] + } + } } } ``` 主题包限制为 30MB,拒绝外部 `url(...)` 与 `@import`。主题包只能包含声明式配置、CSS 和内嵌图片,不能携带或执行 JavaScript。 +`targets..verification` 是可选的主题专属 DOM 依赖: + +- `required`:缺失时阻止注入。 +- `recommended`:缺失时只返回警告,不阻止注入。 +- `contexts`:通过 `when.any` 判断当前页面,只在上下文激活时检查其中的 `required` 和 `recommended`。 + +Core 会在注入前合并适配器基础探针和主题探针。验证结果会返回探针来源、上下文、名称、全部尝试过的选择器以及无效选择器的解析错误。 + 开发主题时先维护源清单和独立 CSS 文件: ```json @@ -115,7 +182,26 @@ codedrobe restore --app workbuddy "version": "1.0.0", "targets": { "codex": { "css": "codex.css" }, - "workbuddy": { "css": "workbuddy.css" } + "workbuddy": { + "css": "workbuddy.css", + "verification": { + "required": [ + { "name": "chat-surface", "any": [".chat-container", ".wb-cb-chat"] } + ], + "recommended": [ + { "name": "conversation-list", "any": [".conversation-list"] } + ], + "contexts": [ + { + "name": "active-chat", + "when": { "any": [".chat-route"] }, + "required": [ + { "name": "message-list", "any": [".message-list"] } + ] + } + ] + } + } } } ``` @@ -127,6 +213,17 @@ codedrobe theme pack ./theme.json --output ./dream-1.0.0.codedrobe-theme codedrobe theme inspect ./dream-1.0.0.codedrobe-theme ``` +打包和检查命令会额外输出非阻断的选择器 lint 警告,包括位置选择器、过深的直接子节点链、依赖本地化文本的属性、生成类名和过长选择器。适配器应只保留跨页面稳定地标;功能页和主题专属 DOM 依赖放在主题 target 中。 + +旧版 Codex 专属主题可以直接转换,不需要在 Skill 中复制 JavaScript: + +```bash +codedrobe theme convert ./legacy.codex-theme \ + --output ./legacy.codedrobe-theme +``` + +转换器会先验证旧主题包,保留 CSS、主题元数据、文案、图片和基础主题选项,再生成只包含 `targets.codex` 的声明式新主题包。输出文件已存在时必须显式传入 `--force` 才会覆盖。 + ## 适配器职责 应用适配器只描述宿主差异: @@ -136,16 +233,18 @@ codedrobe theme inspect ./dream-1.0.0.codedrobe-theme - 独立默认 CDP 端口。 - CDP 页面目标识别规则。 - 页面根节点、工作区和输入区域的验证探针。 +- 每个平台最后一次实机验证的应用版本和日期。 -CDP 会话、注入、重载监听、截图、主题包校验和恢复由通用运行时负责。新增应用时注册新适配器,不复制整套启动脚本或注入器。 +CDP 会话、注入前预检、主题探针合并、缺失节点报告、重载监听、截图、主题包校验和恢复由通用运行时负责。新增应用时注册新适配器,不复制整套启动脚本或注入器。 ## 当前验证状态 - macOS 应用发现:已验证 Codex 与 WorkBuddy。 - `.codedrobe-theme` 双目标打包与读取:已自动化测试。 -- WorkBuddy 实际 DOM 选择器和视觉效果:需要在用户允许重启 WorkBuddy 并开启本地 CDP 后完成截图验收。 +- Codex 26.707.72221(build 5307)macOS 任务页的基础 DOM 探针:已通过本地 CDP 实机验证。 +- WorkBuddy 5.2.6 macOS 欢迎页:已完成 `launch/probe/apply/verify/screenshot/restore` 全流程实机验证,主题探针、视觉截图和恢复均通过。 - Windows 应用发现和启动:已实现适配入口,尚需 Windows 实机验证。 ## 与旧项目的关系 -`codedrobe-codex-skill` 后续应改为调用 `codedrobe` npm 包的 Codex adapter。旧 `.codex-theme` 可由兼容层转换成只包含 `targets.codex` 的 `.codedrobe-theme`,不应继续把 Codex 专属配置写入通用核心。 +`codedrobe-codex-skill` 后续应改为调用 `codedrobe` npm 包的 Codex adapter。旧 `.codex-theme` 使用 `codedrobe theme convert` 转换成只包含 `targets.codex` 的 `.codedrobe-theme`,不应继续把 Codex 专属配置写入通用核心。 diff --git a/examples/dream-1.0.0.codedrobe-theme b/examples/dream-1.0.0.codedrobe-theme index afeb397..bf99fb7 100644 --- a/examples/dream-1.0.0.codedrobe-theme +++ b/examples/dream-1.0.0.codedrobe-theme @@ -1,7 +1,7 @@ { "format": "codedrobe-theme", "schemaVersion": 1, - "exportedAt": "2026-07-16T05:34:55.212Z", + "exportedAt": "2026-07-16T08:29:05.758Z", "theme": { "id": "dream", "displayName": "Dream Multi-App", @@ -9,10 +9,49 @@ }, "targets": { "codex": { - "css": ":root.codedrobe-host-codex {\n --codedrobe-surface: #fff4fa;\n --codedrobe-panel: rgba(255, 255, 255, 0.78);\n --codedrobe-accent: #b65cff;\n --codedrobe-ink: #4a235f;\n}\n\nhtml.codedrobe-host-codex body {\n background: radial-gradient(circle at 80% 10%, #f8dcff, transparent 36%), var(--codedrobe-surface);\n color: var(--codedrobe-ink);\n}\n\nhtml.codedrobe-host-codex aside,\nhtml.codedrobe-host-codex .composer-surface-chrome {\n background-color: var(--codedrobe-panel);\n backdrop-filter: blur(18px);\n}\n" + "css": ":root.codedrobe-host-codex {\n --codedrobe-surface: #fff4fa;\n --codedrobe-panel: rgba(255, 255, 255, 0.78);\n --codedrobe-accent: #b65cff;\n --codedrobe-ink: #4a235f;\n}\n\nhtml.codedrobe-host-codex body {\n background: radial-gradient(circle at 80% 10%, #f8dcff, transparent 36%), var(--codedrobe-surface);\n color: var(--codedrobe-ink);\n}\n\nhtml.codedrobe-host-codex aside,\nhtml.codedrobe-host-codex .composer-surface-chrome {\n background-color: var(--codedrobe-panel);\n backdrop-filter: blur(18px);\n}\n", + "verification": { + "contexts": [ + { + "name": "home", + "when": { + "any": [ + "[role='main']:has([data-testid='home-icon'])" + ] + }, + "recommended": [ + { + "name": "home-suggestions", + "any": [ + ".group\\/home-suggestions" + ] + } + ] + } + ] + } }, "workbuddy": { - "css": ":root.codedrobe-host-workbuddy {\n --codedrobe-surface: #fff4fa;\n --codedrobe-panel: rgba(255, 255, 255, 0.8);\n --codedrobe-accent: #b65cff;\n --codedrobe-ink: #4a235f;\n}\n\nhtml.codedrobe-host-workbuddy body,\nhtml.codedrobe-host-workbuddy .monaco-workbench {\n background-color: var(--codedrobe-surface);\n color: var(--codedrobe-ink);\n}\n\nhtml.codedrobe-host-workbuddy .part.sidebar,\nhtml.codedrobe-host-workbuddy [class*=\"sidebar\"] {\n background-color: var(--codedrobe-panel);\n backdrop-filter: blur(18px);\n}\n\nhtml.codedrobe-host-workbuddy textarea,\nhtml.codedrobe-host-workbuddy [contenteditable=\"true\"] {\n caret-color: var(--codedrobe-accent);\n}\n" + "css": ":root.codedrobe-host-workbuddy {\n --codedrobe-surface: #fff4fa;\n --codedrobe-panel: rgba(255, 255, 255, 0.8);\n --codedrobe-accent: #b65cff;\n --codedrobe-ink: #4a235f;\n}\n\nhtml.codedrobe-host-workbuddy body,\nhtml.codedrobe-host-workbuddy .teams-container,\nhtml.codedrobe-host-workbuddy .teams-main-content {\n background-color: var(--codedrobe-surface);\n color: var(--codedrobe-ink);\n}\n\nhtml.codedrobe-host-workbuddy .conversation-sidebar,\nhtml.codedrobe-host-workbuddy .conversation-list {\n background-color: var(--codedrobe-panel);\n backdrop-filter: blur(18px);\n}\n\nhtml.codedrobe-host-workbuddy [role=\"textbox\"][contenteditable=\"true\"] {\n caret-color: var(--codedrobe-accent);\n}\n", + "verification": { + "required": [ + { + "name": "chat-surface", + "any": [ + ".chat-container", + ".wb-cb-chat" + ] + } + ], + "recommended": [ + { + "name": "conversation-list", + "any": [ + ".conversation-list" + ] + } + ] + } } } } diff --git a/examples/dream/theme.json b/examples/dream/theme.json index 639b4a1..15f7571 100644 --- a/examples/dream/theme.json +++ b/examples/dream/theme.json @@ -5,10 +5,40 @@ "version": "1.0.0", "targets": { "codex": { - "css": "codex.css" + "css": "codex.css", + "verification": { + "contexts": [ + { + "name": "home", + "when": { + "any": ["[role='main']:has([data-testid='home-icon'])"] + }, + "recommended": [ + { + "name": "home-suggestions", + "any": [".group\\/home-suggestions"] + } + ] + } + ] + } }, "workbuddy": { - "css": "workbuddy.css" + "css": "workbuddy.css", + "verification": { + "required": [ + { + "name": "chat-surface", + "any": [".chat-container", ".wb-cb-chat"] + } + ], + "recommended": [ + { + "name": "conversation-list", + "any": [".conversation-list"] + } + ] + } } } } diff --git a/examples/dream/workbuddy.css b/examples/dream/workbuddy.css index 37433d1..3deed21 100644 --- a/examples/dream/workbuddy.css +++ b/examples/dream/workbuddy.css @@ -6,18 +6,18 @@ } html.codedrobe-host-workbuddy body, -html.codedrobe-host-workbuddy .monaco-workbench { +html.codedrobe-host-workbuddy .teams-container, +html.codedrobe-host-workbuddy .teams-main-content { background-color: var(--codedrobe-surface); color: var(--codedrobe-ink); } -html.codedrobe-host-workbuddy .part.sidebar, -html.codedrobe-host-workbuddy [class*="sidebar"] { +html.codedrobe-host-workbuddy .conversation-sidebar, +html.codedrobe-host-workbuddy .conversation-list { background-color: var(--codedrobe-panel); backdrop-filter: blur(18px); } -html.codedrobe-host-workbuddy textarea, -html.codedrobe-host-workbuddy [contenteditable="true"] { +html.codedrobe-host-workbuddy [role="textbox"][contenteditable="true"] { caret-color: var(--codedrobe-accent); } diff --git a/package-lock.json b/package-lock.json index 91d853b..b70b0d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,36 @@ { "name": "@codedrobe/core", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codedrobe/core", - "version": "0.1.1", + "version": "0.2.0", "license": "Apache-2.0", "bin": { "codedrobe": "bin/codedrobe.mjs" }, + "devDependencies": { + "typescript": "^5.9.2" + }, "engines": { "node": ">=22.4" } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } } } } diff --git a/package.json b/package.json index b459477..0205df4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codedrobe/core", - "version": "0.1.1", + "version": "0.2.0", "description": "Cross-platform CodeDrobe runtime, CLI, app adapters, and .codedrobe-theme tooling.", "type": "module", "license": "Apache-2.0", @@ -16,22 +16,37 @@ "codedrobe": "bin/codedrobe.mjs" }, "exports": { - ".": "./src/index.mjs", - "./adapters": "./src/adapters/index.mjs", - "./theme": "./src/theme/package.mjs" + ".": { + "types": "./types/index.d.ts", + "default": "./src/index.mjs" + }, + "./adapters": { + "types": "./types/adapters.d.ts", + "default": "./src/adapters/index.mjs" + }, + "./theme": { + "types": "./types/theme.d.ts", + "default": "./src/theme/index.mjs" + } }, + "types": "./types/index.d.ts", "files": [ "LICENSE", "NOTICE", "README.md", "bin", "src", + "types", "README_zh.md" ], "scripts": { - "test": "node --test", + "test": "node --test && npm run typecheck", + "typecheck": "tsc -p tests/types/tsconfig.json", "pack:check": "npm pack --dry-run" }, + "devDependencies": { + "typescript": "^5.9.2" + }, "engines": { "node": ">=22.4" }, diff --git a/src/adapters/codex.mjs b/src/adapters/codex.mjs index 42aaf3c..cb86c55 100644 --- a/src/adapters/codex.mjs +++ b/src/adapters/codex.mjs @@ -2,6 +2,9 @@ const codex = { id: "codex", displayName: "OpenAI Codex", defaultPort: 9335, + lastVerified: { + darwin: { appVersion: "26.707.72221", build: "5307", verifiedAt: "2026-07-16" }, + }, platforms: { darwin: { bundleId: "com.openai.codex", @@ -19,10 +22,10 @@ const codex = { return target?.type === "page" && String(target.url ?? "").startsWith("app://"); }, verification: { - rootAny: ["main.main-surface", "main"], + rootAny: ["main.main-surface"], required: [ - { name: "sidebar", any: ["aside.app-shell-left-panel", "aside"] }, - { name: "composer", any: [".composer-surface-chrome", "[contenteditable='true']", "textarea"] }, + { name: "sidebar", any: ["aside.app-shell-left-panel"] }, + { name: "composer", any: [".composer-surface-chrome"] }, ], }, }; diff --git a/src/adapters/workbuddy.mjs b/src/adapters/workbuddy.mjs index cb775c8..fc338e1 100644 --- a/src/adapters/workbuddy.mjs +++ b/src/adapters/workbuddy.mjs @@ -2,6 +2,9 @@ const workbuddy = { id: "workbuddy", displayName: "Tencent WorkBuddy", defaultPort: 9336, + lastVerified: { + darwin: { appVersion: "5.2.6", build: "5.2.6", verifiedAt: "2026-07-16" }, + }, platforms: { darwin: { bundleId: "com.workbuddy.workbuddy", @@ -23,14 +26,16 @@ const workbuddy = { const url = String(target.url ?? ""); if (/^(devtools|chrome-extension):/i.test(url)) return false; return /workbuddy/i.test(String(target.title ?? "")) || - /^(workbuddy|vscode-file|file):/i.test(url) || + /app\.asar\/renderer\/index\.html$/i.test(url) || + /^(workbuddy|vscode-file):/i.test(url) || /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//i.test(url); }, verification: { - rootAny: [".monaco-workbench", "[class*='workbench']", "main"], + rootAny: ["#root > .teams-container", ".teams-container", "#root"], required: [ - { name: "workspace", any: [".monaco-workbench", "[class*='workbench']", "main"] }, - { name: "input", any: ["[contenteditable='true']", "textarea", "input[type='text']"] }, + { name: "sidebar", any: [".conversation-sidebar", ".conversation-list"] }, + { name: "workspace", any: [".teams-main-content", ".main-content", ".chat-container"] }, + { name: "composer", any: ["[role='textbox'][contenteditable='true']", ".wb-home-composer [contenteditable='true']"] }, ], }, }; diff --git a/src/cli.mjs b/src/cli.mjs index 56bbcec..36efe07 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,30 +1,36 @@ import path from "node:path"; import { getAdapter, listAdapters } from "./adapters/index.mjs"; import { discoverApp, findRunningPids, launchApp } from "./runtime/launcher.mjs"; -import { applyTheme, captureScreenshot, removeTheme, verifyTheme, watchTheme } from "./runtime/injector.mjs"; -import { readThemePackage, resolveThemeTarget, writeThemePackage } from "./theme/package.mjs"; +import { applyTheme, captureScreenshot, probeApp, removeTheme, verifyTheme, watchTheme } from "./runtime/injector.mjs"; +import { lintThemePackage, readThemePackage, resolveThemeTarget, writeThemePackage } from "./theme/package.mjs"; +import { convertLegacyThemeFile } from "./theme/legacy.mjs"; +import { checkForUpdate, detectPackageManager, formatCommand, getUpdateCommand, maybeNotifyUpdate, updateCodeDrobe } from "./update.mjs"; import { VERSION } from "./version.mjs"; const HELP = `CodeDrobe multi-app theming CLI Usage: codedrobe apps [--json] - codedrobe detect [--app ] [--json] - codedrobe launch --app [--port ] [--restart-existing] [--profile ] - codedrobe apply --app --theme [--port ] [--watch] [--restart-existing] + codedrobe detect [--app ] [--app-path ] [--json] + codedrobe launch --app [--app-path ] [--port ] [--restart-existing] [--profile ] + codedrobe probe --app [--theme ] [--port ] [--timeout-ms ] + codedrobe apply --app --theme [--app-path ] [--port ] [--watch] [--restart-existing] codedrobe verify --app [--theme ] [--port ] [--screenshot ] codedrobe restore --app [--port ] + codedrobe update [--check] [--json] codedrobe theme inspect codedrobe theme pack --output [--force] + codedrobe theme convert --output [--force] Safety: Existing apps are never restarted unless --restart-existing is provided. - CDP is always bound to 127.0.0.1 by the launcher.`; + CDP is always bound to 127.0.0.1 by the launcher. + --app-path accepts an app bundle, installation directory, or executable file.`; function parseArguments(argv) { const options = {}; const positional = []; - const boolean = new Set(["json", "watch", "restart-existing", "no-launch", "force", "help", "version"]); + const boolean = new Set(["json", "watch", "restart-existing", "no-launch", "force", "check", "help", "version"]); for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (!value.startsWith("--")) { @@ -53,6 +59,14 @@ function parsePort(value, fallback) { return port; } +function parseTimeout(value, fallback) { + const timeoutMs = value === undefined ? fallback : Number(value); + if (!Number.isInteger(timeoutMs) || timeoutMs < 250 || timeoutMs > 300000) { + throw new Error(`Invalid timeout '${value}'. Use an integer from 250 to 300000 milliseconds.`); + } + return timeoutMs; +} + function requireOption(options, name) { if (!options[name]) throw new Error(`Missing required option --${name}.`); return options[name]; @@ -64,26 +78,46 @@ function summarizeAdapter(adapter) { displayName: adapter.displayName, defaultPort: adapter.defaultPort, platforms: Object.keys(adapter.platforms), + lastVerified: adapter.lastVerified ?? {}, }; } function ensurePassing(results, action) { const failures = results.filter((item) => item.result?.pass === false); if (failures.length) { - const error = new Error(`${action} verification failed for ${failures.length} renderer target(s).`); + const missing = failures.flatMap((item) => item.result?.missing ?? []); + const detail = missing + .map((item) => `${item.scope}${item.context ? `:${item.context}` : ""}:${item.name} (${item.selectors.join(" | ")})`) + .join("; "); + const error = new Error(`${action} verification failed for ${failures.length} renderer target(s)${detail ? `: ${detail}` : "."}`); + error.code = "CODEDROBE_VERIFY_FAILED"; + error.missing = missing; error.results = results; throw error; } } +function ensureCompatible(results, action) { + const failures = results.filter((item) => item.result?.compatible === false); + if (!failures.length) return; + const missing = failures.flatMap((item) => item.result?.missing ?? []); + const detail = missing + .map((item) => `${item.scope}${item.context ? `:${item.context}` : ""}:${item.name} (${item.selectors.join(" | ")})`) + .join("; "); + const error = new Error(`${action} DOM preflight failed for ${failures.length} renderer target(s)${detail ? `: ${detail}` : "."}`); + error.code = "CODEDROBE_DOM_INCOMPATIBLE"; + error.missing = missing; + error.results = results; + throw error; +} + async function runDetect(options) { + if (options["app-path"] && !options.app) throw new Error("Option --app-path requires --app."); const selected = options.app ? [getAdapter(options.app)] : listAdapters(); const results = []; for (const adapter of selected) { - const [installation, runningPids] = await Promise.all([ - discoverApp(adapter), - findRunningPids(adapter).catch(() => []), - ]); + const installation = await discoverApp(adapter, process.platform, options["app-path"]); + const runningPids = await findRunningPids(adapter, process.platform, installation?.executable).catch(() => []); results.push({ ...summarizeAdapter(adapter), installed: Boolean(installation), @@ -109,6 +143,7 @@ async function runApply(options) { await launchApp({ adapter, port, + appPath: options["app-path"], profilePath: options.profile, restartExisting: Boolean(options["restart-existing"]), }); @@ -126,6 +161,27 @@ async function runApply(options) { } } +async function runProbe(options) { + const adapter = getAdapter(requireOption(options, "app")); + const port = parsePort(options.port, adapter.defaultPort); + const timeoutMs = parseTimeout(options["timeout-ms"], 5000); + const targetTheme = options.theme ? (await loadTargetTheme(options.theme, adapter.id)).targetTheme : null; + if (!options.json) { + console.error(`[codedrobe] Probing ${adapter.displayName} on 127.0.0.1:${port} (timeout ${timeoutMs}ms). Probe does not launch the app.`); + } + let results; + try { + results = await probeApp({ adapter, targetTheme, port, timeoutMs }); + } catch (cause) { + const error = new Error(`${cause.message}\nProbe only inspects an existing CDP session. Start it first with: codedrobe launch --app ${adapter.id} --port ${port}`); + error.code = cause.code; + error.cause = cause; + throw error; + } + output({ action: "probe", appId: adapter.id, port, theme: targetTheme?.theme ?? null, targets: results }, options.json); + ensureCompatible(results, `${adapter.displayName}`); +} + async function runVerify(options) { const adapter = getAdapter(requireOption(options, "app")); const port = parsePort(options.port, adapter.defaultPort); @@ -151,6 +207,7 @@ async function runThemeCommand(positional, options) { targets: Object.keys(bundle.targets), hasArt: Boolean(bundle.assets?.art), exportedAt: bundle.exportedAt, + warnings: lintThemePackage(bundle), }, options.json); return; } @@ -158,14 +215,55 @@ async function runThemeCommand(positional, options) { if (!filename) throw new Error("Theme pack requires a source manifest JSON file."); const outputFilename = requireOption(options, "output"); const result = await writeThemePackage(filename, outputFilename, { force: Boolean(options.force) }); - output({ action: "theme-pack", output: result.output, theme: result.bundle.theme, targets: Object.keys(result.bundle.targets) }, options.json); + output({ + action: "theme-pack", + output: result.output, + theme: result.bundle.theme, + targets: Object.keys(result.bundle.targets), + warnings: lintThemePackage(result.bundle), + }, options.json); + return; + } + if (action === "convert") { + if (!filename) throw new Error("Theme convert requires a legacy .codex-theme file."); + const outputFilename = requireOption(options, "output"); + const result = await convertLegacyThemeFile(filename, outputFilename, { force: Boolean(options.force) }); + output({ + action: "theme-convert", + input: result.input, + output: result.output, + theme: result.bundle.theme, + targets: Object.keys(result.bundle.targets), + warnings: lintThemePackage(result.bundle), + }, options.json); return; } - throw new Error("Theme command must be 'inspect' or 'pack'."); + throw new Error("Theme command must be 'inspect', 'pack', or 'convert'."); } -export async function runCli(argv = process.argv.slice(2)) { - const { positional, options } = parseArguments(argv); +function formatUpdateStatus(status, command) { + if (!status.updateAvailable) return `CodeDrobe ${status.current} is up to date.`; + return `CodeDrobe ${status.latest} is available (current: ${status.current}).\nRun: ${command}`; +} + +async function runUpdate(options) { + const packageManager = detectPackageManager(); + const updateCommand = formatCommand(getUpdateCommand(packageManager)); + if (options.check) { + const status = await checkForUpdate({ force: true, timeoutMs: 10_000 }); + output(options.json ? { action: "update-check", ...status, packageManager, command: updateCommand } : formatUpdateStatus(status, updateCommand), options.json); + return; + } + const result = await updateCodeDrobe({ packageManager, quiet: Boolean(options.json), checkOptions: { timeoutMs: 10_000 } }); + if (options.json) { + output({ action: "update", ...result }, true); + return; + } + if (!result.updated) output(`CodeDrobe ${result.current} is already up to date.`); + else output(`Installed CodeDrobe ${result.latest} globally with ${result.packageManager}. Restart codedrobe to use the new version.`); +} + +async function dispatchCli(positional, options) { const command = positional[0]; if (command === "version" || options.version) { output(VERSION); @@ -181,6 +279,7 @@ export async function runCli(argv = process.argv.slice(2)) { } if (command === "detect") return runDetect(options); if (command === "theme") return runThemeCommand(positional, options); + if (command === "update") return runUpdate(options); const adapter = getAdapter(requireOption(options, "app")); const port = parsePort(options.port, adapter.defaultPort); @@ -188,12 +287,14 @@ export async function runCli(argv = process.argv.slice(2)) { const result = await launchApp({ adapter, port, + appPath: options["app-path"], profilePath: options.profile, restartExisting: Boolean(options["restart-existing"]), }); output(result, options.json); return; } + if (command === "probe") return runProbe(options); if (command === "apply") return runApply(options); if (command === "verify") return runVerify(options); if (command === "restore" || command === "remove") { @@ -204,4 +305,11 @@ export async function runCli(argv = process.argv.slice(2)) { throw new Error(`Unknown command '${command}'. Run 'codedrobe help'.`); } +export async function runCli(argv = process.argv.slice(2)) { + const { positional, options } = parseArguments(argv); + await dispatchCli(positional, options); + const command = positional[0] || (options.version ? "version" : "help"); + await maybeNotifyUpdate({ command, json: Boolean(options.json) }); +} + export { HELP }; diff --git a/src/index.mjs b/src/index.mjs index 5801847..50eab56 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,16 +1,26 @@ export { getAdapter, listAdapters, registerAdapter } from "./adapters/index.mjs"; export { CdpSession, listCdpTargets } from "./cdp/session.mjs"; export { discoverApp, findRunningPids, launchApp } from "./runtime/launcher.mjs"; -export { applyTheme, captureScreenshot, findTargets, removeTheme, verifyTheme, waitForTargets, watchTheme } from "./runtime/injector.mjs"; +export { applyTheme, captureScreenshot, findTargets, probeApp, removeTheme, verifyTheme, waitForTargets, watchTheme } from "./runtime/injector.mjs"; export { MAX_THEME_PACKAGE_BYTES, THEME_EXTENSION, THEME_FORMAT, THEME_SCHEMA_VERSION, buildThemePackage, + lintThemePackage, readThemePackage, resolveThemeTarget, validateThemePackage, writeThemePackage, } from "./theme/package.mjs"; +export { + LEGACY_THEME_EXTENSION, + LEGACY_THEME_FORMAT, + LEGACY_THEME_SCHEMA_VERSION, + convertLegacyThemeFile, + convertLegacyThemePackage, + readLegacyThemePackage, + validateLegacyThemePackage, +} from "./theme/legacy.mjs"; export { VERSION } from "./version.mjs"; diff --git a/src/runtime/injector.mjs b/src/runtime/injector.mjs index 8e6df12..8c1fb66 100644 --- a/src/runtime/injector.mjs +++ b/src/runtime/injector.mjs @@ -1,12 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import { CdpSession, listCdpTargets } from "../cdp/session.mjs"; -import { buildApplyExpression, buildRemoveExpression, buildVerifyExpression } from "./renderer-payload.mjs"; +import { buildApplyExpression, buildProbeExpression, buildRemoveExpression, buildVerifyExpression } from "./renderer-payload.mjs"; const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); -export async function findTargets(adapter, port) { - const targets = await listCdpTargets(port); +export async function findTargets(adapter, port, timeoutMs = 1500) { + const targets = await listCdpTargets(port, timeoutMs); return targets.filter((target) => adapter.matchTarget(target)); } @@ -15,14 +15,21 @@ export async function waitForTargets(adapter, port, timeoutMs = 30000) { let lastError; while (Date.now() < deadline) { try { - const targets = await findTargets(adapter, port); + const remaining = Math.max(1, deadline - Date.now()); + const targets = await findTargets(adapter, port, Math.min(1500, remaining)); if (targets.length) return targets; } catch (error) { lastError = error; } - await delay(350); + const remaining = deadline - Date.now(); + if (remaining > 0) await delay(Math.min(350, remaining)); } - throw new Error(`No ${adapter.displayName} renderer target on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`); + const error = new Error(`No ${adapter.displayName} renderer target on 127.0.0.1:${port} within ${timeoutMs}ms: ${lastError?.message ?? "timed out"}`); + error.code = "CODEDROBE_TARGET_TIMEOUT"; + error.appId = adapter.id; + error.port = port; + error.timeoutMs = timeoutMs; + throw error; } async function withSessions(targets, callback) { @@ -38,19 +45,63 @@ async function withSessions(targets, callback) { return results; } +function compatibilityError(adapter, results) { + const missing = results.flatMap((item) => item.result?.missing ?? []); + const detail = missing + .map((item) => `${item.scope}${item.context ? `:${item.context}` : ""}:${item.name} (${item.selectors.join(" | ")})`) + .join("; "); + const error = new Error(`${adapter.displayName} DOM preflight failed${detail ? `: ${detail}` : "."}`); + error.code = "CODEDROBE_DOM_INCOMPATIBLE"; + error.missing = missing; + error.results = results; + return error; +} + +function ensureCompatible(adapter, results) { + if (results.every((item) => item.result?.compatible)) return results; + throw compatibilityError(adapter, results); +} + +async function waitForCompatibility(session, expression, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + let result; + do { + result = await session.evaluate(expression); + if (result?.compatible) return result; + await delay(250); + } while (Date.now() < deadline); + return result; +} + +export async function probeApp({ adapter, targetTheme = null, port, timeoutMs = 5000 }) { + const targets = await waitForTargets(adapter, port, timeoutMs); + const expression = buildProbeExpression(adapter, targetTheme?.verification ?? null); + return withSessions(targets, (session) => waitForCompatibility(session, expression, Math.min(timeoutMs, 5000))); +} + export async function applyTheme({ adapter, targetTheme, port, timeoutMs = 30000 }) { const targets = await waitForTargets(adapter, port, timeoutMs); + const preflightExpression = buildProbeExpression(adapter, targetTheme.verification); + const preflight = await withSessions( + targets, + (session) => waitForCompatibility(session, preflightExpression, Math.min(timeoutMs, 5000)), + ); + ensureCompatible(adapter, preflight); const expression = buildApplyExpression({ adapter, targetTheme }); return withSessions(targets, async (session) => { await session.evaluate(expression); await delay(500); - return session.evaluate(buildVerifyExpression(adapter, targetTheme.theme)); + return session.evaluate(buildVerifyExpression(adapter, targetTheme.theme, targetTheme.verification)); }); } export async function verifyTheme({ adapter, targetTheme, port, timeoutMs = 30000 }) { const targets = await waitForTargets(adapter, port, timeoutMs); - return withSessions(targets, (session) => session.evaluate(buildVerifyExpression(adapter, targetTheme?.theme ?? null))); + return withSessions(targets, (session) => session.evaluate(buildVerifyExpression( + adapter, + targetTheme?.theme ?? null, + targetTheme?.verification ?? null, + ))); } export async function removeTheme({ adapter, port, timeoutMs = 30000 }) { @@ -78,6 +129,7 @@ export async function captureScreenshot({ adapter, port, output, timeoutMs = 300 export async function watchTheme({ adapter, targetTheme, port, timeoutMs = 30000, onEvent = () => {} }) { const expression = buildApplyExpression({ adapter, targetTheme }); + const preflightExpression = buildProbeExpression(adapter, targetTheme.verification); const sessions = new Map(); let stopping = false; const stop = () => { stopping = true; }; @@ -102,18 +154,27 @@ export async function watchTheme({ adapter, targetTheme, port, timeoutMs = 30000 } for (const target of targets) { if (sessions.has(target.id)) continue; + let session; try { - const session = await new CdpSession(target).open(); + session = await new CdpSession(target).open(); + const applyCompatible = async () => { + const result = await waitForCompatibility(session, preflightExpression, Math.min(timeoutMs, 5000)); + ensureCompatible(adapter, [{ targetId: target.id, title: target.title, url: target.url, result }]); + await session.evaluate(expression); + }; session.on("Page.loadEventFired", () => { - setTimeout(() => session.evaluate(expression).catch((error) => { - onEvent({ type: "error", message: error.message }); + setTimeout(() => applyCompatible().catch((error) => { + onEvent({ type: "error", code: error.code, message: error.message, missing: error.missing ?? [] }); + session.close(); + sessions.delete(target.id); }), 250); }); - await session.evaluate(expression); + await applyCompatible(); sessions.set(target.id, session); onEvent({ type: "injected", targetId: target.id, title: target.title }); } catch (error) { - onEvent({ type: "error", targetId: target.id, message: error.message }); + session?.close(); + onEvent({ type: "error", targetId: target.id, code: error.code, message: error.message, missing: error.missing ?? [] }); } } await delay(900); diff --git a/src/runtime/launcher.mjs b/src/runtime/launcher.mjs index 206ea48..3f545ba 100644 --- a/src/runtime/launcher.mjs +++ b/src/runtime/launcher.mjs @@ -16,13 +16,35 @@ function expandPath(value) { async function isExecutable(filename) { try { - await fs.access(filename); - return true; + const stats = await fs.stat(filename); + return stats.isFile(); } catch { return false; } } +async function discoverCustom(adapter, config, appPath, platform) { + const pathApi = platform === "win32" ? path.win32 : path; + const resolved = path.resolve(expandPath(appPath)); + const relativeExecutables = []; + if (config.executableRelative) relativeExecutables.push(config.executableRelative); + for (const candidate of config.executableCandidates ?? []) { + relativeExecutables.push(pathApi.basename(candidate)); + } + relativeExecutables.push(...(config.processNames ?? [])); + + for (const relative of [...new Set(relativeExecutables)]) { + const executable = pathApi.join(resolved, relative); + if (await isExecutable(executable)) { + return { appId: adapter.id, appPath: resolved, executable }; + } + } + if (await isExecutable(resolved)) { + return { appId: adapter.id, appPath: pathApi.dirname(resolved), executable: resolved }; + } + return null; +} + async function discoverMac(adapter, config) { const candidates = config.appCandidates.map(expandPath); if (config.bundleId) { @@ -57,27 +79,32 @@ async function discoverWindows(adapter, config) { return null; } -export async function discoverApp(adapter, platform = process.platform) { +export async function discoverApp(adapter, platform = process.platform, appPath = null) { const config = adapter.platforms[platform]; if (!config) return null; + if (appPath) return discoverCustom(adapter, config, appPath, platform); if (platform === "darwin") return discoverMac(adapter, config); if (platform === "win32") return discoverWindows(adapter, config); return null; } -export async function findRunningPids(adapter, platform = process.platform) { +export async function findRunningPids(adapter, platform = process.platform, executablePath = null) { const config = adapter.platforms[platform]; if (!config) return []; if (platform === "darwin") { const { stdout } = await execFileAsync("ps", ["-axo", "pid=,command="]); + const markers = [...(config.processMarkers ?? []), executablePath].filter(Boolean); return stdout.split(/\r?\n/).flatMap((line) => { const match = /^\s*(\d+)\s+(.+)$/.exec(line); - if (!match || !(config.processMarkers ?? []).some((marker) => match[2].includes(marker))) return []; + if (!match || !markers.some((marker) => match[2].includes(marker))) return []; return [Number(match[1])]; }); } if (platform === "win32") { - const names = JSON.stringify(config.processNames ?? []); + const names = JSON.stringify([ + ...(config.processNames ?? []), + ...(executablePath ? [path.win32.basename(executablePath)] : []), + ]); const script = `$names = ConvertFrom-Json '${names.replaceAll("'", "''")}'; Get-Process | Where-Object { $names -contains ($_.Name + '.exe') -or $names -contains $_.Name } | Select-Object -ExpandProperty Id`; const { stdout } = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script]); return stdout.split(/\r?\n/).map(Number).filter(Number.isInteger); @@ -85,7 +112,7 @@ export async function findRunningPids(adapter, platform = process.platform) { return []; } -async function stopExisting(adapter, pids, platform = process.platform) { +async function stopExisting(adapter, pids, platform = process.platform, executablePath = null) { const config = adapter.platforms[platform]; if (platform === "darwin" && config.bundleId) { await execFileAsync("osascript", ["-e", `tell application id "${config.bundleId}" to quit`]).catch(() => {}); @@ -94,7 +121,7 @@ async function stopExisting(adapter, pids, platform = process.platform) { await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script]).catch(() => {}); } for (let attempt = 0; attempt < 30; attempt += 1) { - if (!(await findRunningPids(adapter, platform)).length) return; + if (!(await findRunningPids(adapter, platform, executablePath)).length) return; await delay(250); } if (platform !== "win32") { @@ -104,22 +131,28 @@ async function stopExisting(adapter, pids, platform = process.platform) { } } -export async function launchApp({ adapter, port = adapter.defaultPort, profilePath = null, restartExisting = false, timeoutMs = 30000 }) { +export async function launchApp({ adapter, port = adapter.defaultPort, appPath = null, profilePath = null, restartExisting = false, timeoutMs = 30000 }) { try { const targets = await findTargets(adapter, port); if (targets.length) return { appId: adapter.id, port, alreadyReady: true, targets: targets.length }; } catch { /* Launch when the endpoint is absent. */ } - const runningPids = await findRunningPids(adapter); + const discovered = await discoverApp(adapter, process.platform, appPath); + if (!discovered) { + if (appPath) { + throw new Error(`${adapter.displayName} executable was not found from --app-path '${path.resolve(expandPath(appPath))}'.`); + } + throw new Error(`${adapter.displayName} is not installed or could not be discovered.`); + } + + const runningPids = await findRunningPids(adapter, process.platform, discovered.executable); if (runningPids.length) { if (!restartExisting) { throw new Error(`${adapter.displayName} is already running without CodeDrobe on port ${port}. Close it or pass --restart-existing.`); } - await stopExisting(adapter, runningPids); + await stopExisting(adapter, runningPids, process.platform, discovered.executable); } - const discovered = await discoverApp(adapter); - if (!discovered) throw new Error(`${adapter.displayName} is not installed or could not be discovered.`); const args = [`--remote-debugging-address=127.0.0.1`, `--remote-debugging-port=${port}`]; if (profilePath) { const resolved = path.resolve(profilePath); diff --git a/src/runtime/renderer-payload.mjs b/src/runtime/renderer-payload.mjs index 9205532..8d6229f 100644 --- a/src/runtime/renderer-payload.mjs +++ b/src/runtime/renderer-payload.mjs @@ -2,6 +2,115 @@ function safeHostClass(appId) { return `codedrobe-host-${String(appId).replace(/[^a-z0-9_-]/gi, "-")}`; } +function buildCompatibilityProfile(adapter, themeVerification = null) { + const adapterProfile = adapter.verification ?? { rootAny: ["body"], required: [] }; + const checks = (verification, scope, context = null) => [ + ...(verification?.required ?? []).map((item) => ({ ...item, scope, context, severity: "required" })), + ...(verification?.recommended ?? []).map((item) => ({ ...item, scope, context, severity: "recommended" })), + ]; + const contexts = (verification, scope) => (verification?.contexts ?? []).map((context) => ({ + name: context.name, + scope, + whenAny: context.when.any, + checks: checks(context, scope, context.name), + })); + return { + rootAny: adapterProfile.rootAny ?? ["body"], + checks: [ + ...checks(adapterProfile, "adapter"), + ...checks(themeVerification, "theme"), + ], + contexts: [ + ...contexts(adapterProfile, "adapter"), + ...contexts(themeVerification, "theme"), + ], + }; +} + +function buildCompatibilityPrelude(adapter, themeVerification = null) { + const profile = JSON.stringify(buildCompatibilityProfile(adapter, themeVerification)); + const appId = JSON.stringify(adapter.id); + return ` + const appId = ${appId}; + const profile = ${profile}; + const inspect = (selector) => { + try { return { selector, node: document.querySelector(selector), valid: true, error: null }; } + catch (error) { return { selector, node: null, valid: false, error: error?.message ?? String(error) }; } + }; + const visible = (node) => { + if (!node) return false; + const box = node.getBoundingClientRect(); + const style = getComputedStyle(node); + return box.width > 0 && box.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; + }; + const evaluateSelectors = (selectors) => { + const inspected = selectors.map(inspect); + return { + matches: inspected.filter((item) => item.valid && visible(item.node)).map((item) => item.selector), + invalidSelectors: inspected.filter((item) => !item.valid).map((item) => ({ selector: item.selector, error: item.error })), + }; + }; + const root = evaluateSelectors(profile.rootAny); + const contexts = profile.contexts.map((context) => { + const trigger = evaluateSelectors(context.whenAny); + return { + scope: context.scope, + name: context.name, + active: trigger.matches.length > 0, + matches: trigger.matches, + selectors: context.whenAny, + invalidSelectors: trigger.invalidSelectors, + }; + }); + const activeContexts = new Set(contexts.filter((context) => context.active).map((context) => context.scope + ':' + context.name)); + const checks = [ + ...profile.checks, + ...profile.contexts.flatMap((context) => activeContexts.has(context.scope + ':' + context.name) ? context.checks : []), + ]; + const requirements = checks.map((item) => { + const evaluated = evaluateSelectors(item.any); + return { + scope: item.scope, + context: item.context, + severity: item.severity, + name: item.name, + pass: evaluated.matches.length > 0, + matches: evaluated.matches, + selectors: item.any, + invalidSelectors: evaluated.invalidSelectors, + }; + }); + const diagnostic = (item) => ({ + scope: item.scope, + context: item.context, + severity: item.severity, + name: item.name, + selectors: item.selectors, + invalidSelectors: item.invalidSelectors, + }); + const missing = []; + const warnings = []; + if (!root.matches.length) missing.push({ + scope: 'adapter', context: null, severity: 'required', name: 'root', + selectors: profile.rootAny, invalidSelectors: root.invalidSelectors, + }); + for (const item of requirements) { + if (!item.pass && item.severity === 'required') missing.push(diagnostic(item)); + if (!item.pass && item.severity === 'recommended') warnings.push(diagnostic(item)); + } + const compatibility = { + appId, + compatible: missing.length === 0, + rootMatches: root.matches, + rootInvalidSelectors: root.invalidSelectors, + contexts, + requirements, + missing, + warnings, + viewport: { width: innerWidth, height: innerHeight }, + };`; +} + export function buildApplyExpression({ adapter, targetTheme }) { const host = JSON.stringify({ id: adapter.id, className: safeHostClass(adapter.id) }); const theme = JSON.stringify(targetTheme.theme); @@ -81,41 +190,29 @@ export function buildRemoveExpression(adapter) { })()`; } -export function buildVerifyExpression(adapter, expectedTheme = null) { - const profile = JSON.stringify(adapter.verification ?? { rootAny: ["body"], required: [] }); - const appId = JSON.stringify(adapter.id); +export function buildProbeExpression(adapter, themeVerification = null) { + return `(() => { + ${buildCompatibilityPrelude(adapter, themeVerification)} + return compatibility; + })()`; +} + +export function buildVerifyExpression(adapter, expectedTheme = null, themeVerification = null) { const expected = JSON.stringify(expectedTheme); return `(() => { - const appId = ${appId}; - const profile = ${profile}; + ${buildCompatibilityPrelude(adapter, themeVerification)} const expected = ${expected}; - const query = (selector) => { try { return document.querySelector(selector); } catch { return null; } }; - const visible = (node) => { - if (!node) return false; - const box = node.getBoundingClientRect(); - const style = getComputedStyle(node); - return box.width > 0 && box.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; - }; const state = window.__CODEDROBE__?.hosts?.[appId]; - const rootMatches = (profile.rootAny ?? ['body']).filter((selector) => visible(query(selector))); - const requirements = (profile.required ?? []).map((item) => { - const matches = item.any.filter((selector) => visible(query(selector))); - return { name: item.name, pass: matches.length > 0, matches }; - }); const result = { + ...compatibility, installed: Boolean(state), - appId, themeId: state?.themeId ?? null, version: state?.version ?? null, stylePresent: Boolean(document.getElementById('codedrobe-theme-style-' + appId)), - rootMatches, - requirements, - viewport: { width: innerWidth, height: innerHeight }, horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, }; const themeMatches = !expected || (result.themeId === expected.id && result.version === expected.version); - result.pass = result.installed && result.stylePresent && rootMatches.length > 0 && - requirements.every((item) => item.pass) && themeMatches && !result.horizontalOverflow; + result.pass = result.compatible && result.installed && result.stylePresent && themeMatches && !result.horizontalOverflow; return result; })()`; } diff --git a/src/theme/index.mjs b/src/theme/index.mjs new file mode 100644 index 0000000..7f2efe2 --- /dev/null +++ b/src/theme/index.mjs @@ -0,0 +1,2 @@ +export * from "./package.mjs"; +export * from "./legacy.mjs"; diff --git a/src/theme/legacy.mjs b/src/theme/legacy.mjs new file mode 100644 index 0000000..e6488d0 --- /dev/null +++ b/src/theme/legacy.mjs @@ -0,0 +1,150 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + MAX_THEME_PACKAGE_BYTES, + THEME_EXTENSION, + THEME_FORMAT, + THEME_SCHEMA_VERSION, + validateThemePackage, +} from "./package.mjs"; + +export const LEGACY_THEME_FORMAT = "codex-theme"; +export const LEGACY_THEME_EXTENSION = ".codex-theme"; +export const LEGACY_THEME_SCHEMA_VERSION = 1; + +const SAFE_ID = /^[a-z0-9][a-z0-9_-]*$/i; +const REMOTE_CSS = /@import\s|url\(\s*["']?(?!data:)/i; +const SAFE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); + +function assertString(value, label) { + if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`); +} + +export function validateLegacyThemePackage(bundle) { + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + throw new Error("Legacy theme package must be a JSON object."); + } + if (bundle.format !== LEGACY_THEME_FORMAT || bundle.schemaVersion !== LEGACY_THEME_SCHEMA_VERSION) { + throw new Error("Unsupported legacy .codex-theme format or schemaVersion."); + } + if (!bundle.manifest || typeof bundle.manifest !== "object" || Array.isArray(bundle.manifest)) { + throw new Error("Legacy theme package requires a manifest object."); + } + if (bundle.manifest.schemaVersion !== LEGACY_THEME_SCHEMA_VERSION) { + throw new Error("Unsupported legacy manifest schemaVersion."); + } + assertString(bundle.manifest.id, "manifest.id"); + assertString(bundle.manifest.displayName, "manifest.displayName"); + assertString(bundle.manifest.version, "manifest.version"); + assertString(bundle.manifest.css, "manifest.css"); + assertString(bundle.css, "css"); + if (!SAFE_ID.test(bundle.manifest.id)) throw new Error(`Invalid legacy theme id '${bundle.manifest.id}'.`); + if (bundle.manifest.css !== "theme.css") throw new Error("Legacy manifest.css must be the portable theme.css entry."); + if (REMOTE_CSS.test(bundle.css)) throw new Error("Legacy theme contains an external CSS resource."); + for (const field of ["copy", "baseTheme"]) { + const value = bundle.manifest[field]; + if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) { + throw new Error(`Legacy manifest.${field} must be an object.`); + } + } + + if (bundle.manifest.art && !bundle.art) { + throw new Error("Legacy theme manifest references artwork that is missing from the package."); + } + if (!bundle.manifest.art && bundle.art) { + throw new Error("Legacy theme contains artwork that is not declared by manifest.art."); + } + if (bundle.art !== undefined) { + if (!bundle.art || typeof bundle.art !== "object" || Array.isArray(bundle.art)) { + throw new Error("Legacy theme art must be an object."); + } + assertString(bundle.art.filename, "art.filename"); + assertString(bundle.art.mimeType, "art.mimeType"); + assertString(bundle.art.base64, "art.base64"); + if (path.basename(bundle.art.filename) !== bundle.art.filename) { + throw new Error("Legacy art.filename must be a safe basename."); + } + if (bundle.manifest.art !== bundle.art.filename) { + throw new Error("Legacy manifest.art must match art.filename."); + } + if (!SAFE_IMAGE_TYPES.has(bundle.art.mimeType)) { + throw new Error(`Unsupported legacy artwork MIME type '${bundle.art.mimeType}'.`); + } + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(bundle.art.base64)) { + throw new Error("Legacy art.base64 must contain valid Base64 data."); + } + } + return bundle; +} + +export function convertLegacyThemePackage(legacyBundle) { + const legacy = validateLegacyThemePackage(legacyBundle); + const { manifest } = legacy; + const options = { + legacy: { + format: LEGACY_THEME_FORMAT, + schemaVersion: LEGACY_THEME_SCHEMA_VERSION, + ...(legacy.exportedAt ? { exportedAt: legacy.exportedAt } : {}), + }, + ...(manifest.baseTheme ? { baseTheme: manifest.baseTheme } : {}), + }; + return validateThemePackage({ + format: THEME_FORMAT, + schemaVersion: THEME_SCHEMA_VERSION, + exportedAt: new Date().toISOString(), + theme: { + id: manifest.id, + displayName: manifest.displayName, + version: manifest.version, + ...(manifest.copy ? { copy: manifest.copy } : {}), + }, + targets: { + codex: { + css: legacy.css, + options, + }, + }, + ...(legacy.art ? { + assets: { + art: { + filename: legacy.art.filename, + mimeType: legacy.art.mimeType, + base64: legacy.art.base64, + }, + }, + } : {}), + }); +} + +export async function readLegacyThemePackage(filename) { + if (path.extname(filename) !== LEGACY_THEME_EXTENSION) { + throw new Error(`Legacy theme packages must use the ${LEGACY_THEME_EXTENSION} extension.`); + } + const stat = await fs.stat(filename); + if (stat.size > MAX_THEME_PACKAGE_BYTES) throw new Error("Legacy theme package exceeds the 30MB limit."); + return validateLegacyThemePackage(JSON.parse(await fs.readFile(filename, "utf8"))); +} + +export async function convertLegacyThemeFile(inputFilename, outputFilename, { force = false } = {}) { + const input = path.resolve(inputFilename); + const output = path.resolve(outputFilename); + if (path.extname(output) !== THEME_EXTENSION) { + throw new Error(`Converted output filename must end with ${THEME_EXTENSION}.`); + } + if (!force) { + try { + await fs.access(output); + throw new Error(`Output already exists: ${output}`); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + } + const bundle = convertLegacyThemePackage(await readLegacyThemePackage(input)); + const serialized = `${JSON.stringify(bundle, null, 2)}\n`; + if (Buffer.byteLength(serialized) > MAX_THEME_PACKAGE_BYTES) { + throw new Error("Converted theme package exceeds the 30MB limit."); + } + await fs.mkdir(path.dirname(output), { recursive: true }); + await fs.writeFile(output, serialized, "utf8"); + return { input, output, bundle }; +} diff --git a/src/theme/package.mjs b/src/theme/package.mjs index d0c0dad..33a7ae5 100644 --- a/src/theme/package.mjs +++ b/src/theme/package.mjs @@ -8,6 +8,13 @@ export const MAX_THEME_PACKAGE_BYTES = 30 * 1024 * 1024; const SAFE_ID = /^[a-z0-9][a-z0-9_-]*$/i; const REMOTE_CSS = /@import\s|url\(\s*["']?(?!data:)/i; +const MAX_VERIFICATION_REQUIREMENTS = 32; +const MAX_VERIFICATION_CONTEXTS = 16; +const MAX_SELECTORS_PER_REQUIREMENT = 16; +const MAX_SELECTOR_LENGTH = 1024; +const SELECTOR_WARNING_LENGTH = 180; +const MAX_LINT_WARNINGS = 100; +const MAX_LINT_SELECTOR_DISPLAY_LENGTH = 240; function assertString(value, label) { if (typeof value !== "string" || !value.trim()) throw new Error(`${label} must be a non-empty string.`); @@ -23,12 +30,165 @@ function mimeTypeFor(filename) { } } +function validateSelectorArray(selectors, label) { + if (!Array.isArray(selectors) || !selectors.length) { + throw new Error(`${label} must be a non-empty selector array.`); + } + if (selectors.length > MAX_SELECTORS_PER_REQUIREMENT) { + throw new Error(`${label} exceeds ${MAX_SELECTORS_PER_REQUIREMENT} selectors.`); + } + for (const [selectorIndex, selector] of selectors.entries()) { + assertString(selector, `${label}[${selectorIndex}]`); + if (selector.length > MAX_SELECTOR_LENGTH || selector.includes("\0")) { + throw new Error(`${label}[${selectorIndex}] is not a safe selector.`); + } + } +} + +function validateRequirementList(requirements, label, names) { + if (requirements === undefined) return 0; + if (!Array.isArray(requirements) || !requirements.length) { + throw new Error(`${label} must be a non-empty array when provided.`); + } + let count = 0; + for (const [index, requirement] of requirements.entries()) { + const itemLabel = `${label}[${index}]`; + assertString(requirement?.name, `${itemLabel}.name`); + if (!SAFE_ID.test(requirement.name)) throw new Error(`${itemLabel}.name must be a safe id.`); + if (names.has(requirement.name)) throw new Error(`${label} contains duplicate requirement '${requirement.name}'.`); + names.add(requirement.name); + validateSelectorArray(requirement.any, `${itemLabel}.any`); + count += 1; + } + return count; +} + +function validateVerification(verification, label) { + if (verification === undefined) return; + if (!verification || typeof verification !== "object" || Array.isArray(verification)) { + throw new Error(`${label} must be an object.`); + } + let requirementCount = 0; + const names = new Set(); + requirementCount += validateRequirementList(verification.required, `${label}.required`, names); + requirementCount += validateRequirementList(verification.recommended, `${label}.recommended`, names); + + if (verification.contexts !== undefined) { + if (!Array.isArray(verification.contexts) || !verification.contexts.length) { + throw new Error(`${label}.contexts must be a non-empty array when provided.`); + } + if (verification.contexts.length > MAX_VERIFICATION_CONTEXTS) { + throw new Error(`${label}.contexts exceeds ${MAX_VERIFICATION_CONTEXTS} entries.`); + } + const contextNames = new Set(); + for (const [index, context] of verification.contexts.entries()) { + const contextLabel = `${label}.contexts[${index}]`; + assertString(context?.name, `${contextLabel}.name`); + if (!SAFE_ID.test(context.name)) throw new Error(`${contextLabel}.name must be a safe id.`); + if (contextNames.has(context.name)) throw new Error(`${label} contains duplicate context '${context.name}'.`); + contextNames.add(context.name); + if (!context.when || typeof context.when !== "object" || Array.isArray(context.when)) { + throw new Error(`${contextLabel}.when must be an object.`); + } + validateSelectorArray(context.when.any, `${contextLabel}.when.any`); + const names = new Set(); + const contextCount = validateRequirementList(context.required, `${contextLabel}.required`, names) + + validateRequirementList(context.recommended, `${contextLabel}.recommended`, names); + if (!contextCount) throw new Error(`${contextLabel} must declare required or recommended checks.`); + requirementCount += contextCount; + } + } + + if (!requirementCount) { + throw new Error(`${label} must declare required, recommended, or contextual checks.`); + } + if (requirementCount > MAX_VERIFICATION_REQUIREMENTS) { + throw new Error(`${label} exceeds ${MAX_VERIFICATION_REQUIREMENTS} total requirements.`); + } +} + +function extractCssSelectorBlocks(css) { + const source = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const selectors = []; + let boundary = 0; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (character === "{") { + const prelude = source.slice(boundary, index).trim(); + if (prelude && !prelude.startsWith("@")) selectors.push(prelude); + boundary = index + 1; + } else if (character === "}") { + boundary = index + 1; + } + } + return selectors; +} + +function lintSelector(selector, metadata) { + const warnings = []; + const displaySelector = selector.replace(/\s+/g, " ").trim().slice(0, MAX_LINT_SELECTOR_DISPLAY_LENGTH); + const add = (code, message) => warnings.push({ code, ...metadata, selector: displaySelector, message }); + if (selector.length > SELECTOR_WARNING_LENGTH) { + add("long-selector", `Selector is ${selector.length} characters long and may be coupled to DOM structure.`); + } + if (/:?(?:first|last|nth)-(?:child|of-type)\b/i.test(selector)) { + add("positional-selector", "Positional selectors often break when the application inserts or reorders nodes."); + } + if ((selector.match(/>/g) ?? []).length >= 3) { + add("deep-child-chain", "Deep direct-child chains are sensitive to wrapper changes."); + } + if (/\[(?:aria-label|title|placeholder)[*^$|~]?=\s*["'][^"']+["']\]/i.test(selector)) { + add("localized-attribute", "Text-bearing accessibility attributes may change with locale or product copy."); + } + if (/\.[a-z_-][\w-]*__[a-z0-9_-]+__[a-z0-9_-]{5,}/i.test(selector)) { + add("generated-class", "Generated class names are not stable application landmarks."); + } + return warnings; +} + +function verificationSelectors(verification, prefix) { + if (!verification) return []; + const entries = []; + const append = (requirements, location) => { + for (const requirement of requirements ?? []) { + for (const selector of requirement.any) entries.push({ selector, location: `${location}.${requirement.name}` }); + } + }; + append(verification.required, `${prefix}.required`); + append(verification.recommended, `${prefix}.recommended`); + for (const context of verification.contexts ?? []) { + for (const selector of context.when.any) entries.push({ selector, location: `${prefix}.contexts.${context.name}.when` }); + append(context.required, `${prefix}.contexts.${context.name}.required`); + append(context.recommended, `${prefix}.contexts.${context.name}.recommended`); + } + return entries; +} + +export function lintThemePackage(bundle) { + validateThemePackage(bundle); + const warnings = []; + for (const [appId, target] of Object.entries(bundle.targets)) { + for (const selector of extractCssSelectorBlocks(target.css)) { + warnings.push(...lintSelector(selector, { appId, location: `targets.${appId}.css` })); + } + for (const entry of verificationSelectors(target.verification, `targets.${appId}.verification`)) { + warnings.push(...lintSelector(entry.selector, { appId, location: entry.location })); + } + } + const unique = new Map(warnings.map((warning) => [ + `${warning.code}\0${warning.appId}\0${warning.location}\0${warning.selector}`, + warning, + ])); + return [...unique.values()].slice(0, MAX_LINT_WARNINGS); +} + function validateTarget(target, appId) { if (!SAFE_ID.test(appId)) throw new Error(`Invalid target app id '${appId}'.`); assertString(target?.css, `targets.${appId}.css`); if (REMOTE_CSS.test(target.css)) { throw new Error(`Target '${appId}' contains an external CSS resource.`); } + validateVerification(target.verification, `targets.${appId}.verification`); } export function validateThemePackage(bundle) { @@ -80,6 +240,7 @@ export function resolveThemeTarget(bundle, appId) { theme: bundle.theme, css: target.css, options: target.options ?? {}, + verification: target.verification ?? null, artDataUrl: art ? `data:${art.mimeType};base64,${art.base64}` : null, }; } @@ -95,7 +256,11 @@ export async function buildThemePackage(manifestFilename) { for (const [appId, target] of Object.entries(source.targets ?? {})) { assertString(target?.css, `targets.${appId}.css`); const css = await fs.readFile(path.resolve(base, target.css), "utf8"); - targets[appId] = { css, ...(target.options ? { options: target.options } : {}) }; + targets[appId] = { + css, + ...(target.options ? { options: target.options } : {}), + ...(target.verification ? { verification: target.verification } : {}), + }; } let assets; diff --git a/src/update.mjs b/src/update.mjs new file mode 100644 index 0000000..d3c8606 --- /dev/null +++ b/src/update.mjs @@ -0,0 +1,245 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { VERSION } from "./version.mjs"; + +export const PACKAGE_NAME = "@codedrobe/core"; +export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_REGISTRY = "https://registry.npmjs.org"; +const PACKAGE_ROOT = fileURLToPath(new URL("../", import.meta.url)); + +function parseVersion(value) { + const match = String(value).trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/); + if (!match) throw new Error(`Invalid semantic version '${value}'.`); + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease: match[4]?.split(".") ?? [], + }; +} + +function comparePrerelease(left, right) { + if (!left.length && !right.length) return 0; + if (!left.length) return 1; + if (!right.length) return -1; + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + if (left[index] === undefined) return -1; + if (right[index] === undefined) return 1; + if (left[index] === right[index]) continue; + const leftNumber = /^\d+$/.test(left[index]) ? Number(left[index]) : null; + const rightNumber = /^\d+$/.test(right[index]) ? Number(right[index]) : null; + if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber); + if (leftNumber !== null) return -1; + if (rightNumber !== null) return 1; + return left[index] < right[index] ? -1 : 1; + } + return 0; +} + +export function compareVersions(leftValue, rightValue) { + const left = parseVersion(leftValue); + const right = parseVersion(rightValue); + for (const key of ["major", "minor", "patch"]) { + if (left[key] !== right[key]) return Math.sign(left[key] - right[key]); + } + return comparePrerelease(left.prerelease, right.prerelease); +} + +export function getUpdateCacheFile({ env = process.env, platform = process.platform, home = os.homedir() } = {}) { + if (env.CODEDROBE_UPDATE_CACHE) return path.resolve(env.CODEDROBE_UPDATE_CACHE); + if (env.CODEDROBE_CACHE_DIR) return path.resolve(env.CODEDROBE_CACHE_DIR, "update-check.json"); + if (platform === "darwin") return path.join(home, "Library", "Caches", "codedrobe", "update-check.json"); + if (platform === "win32") { + const base = env.LOCALAPPDATA || path.join(home, "AppData", "Local"); + return path.join(base, "CodeDrobe", "Cache", "update-check.json"); + } + return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "codedrobe", "update-check.json"); +} + +async function readFreshCache(cacheFile, now, maxAgeMs) { + try { + const cached = JSON.parse(await fs.readFile(cacheFile, "utf8")); + const checkedAt = Date.parse(cached.checkedAt); + if ( + cached.packageName !== PACKAGE_NAME + || typeof cached.latest !== "string" + || !Number.isFinite(checkedAt) + || now - checkedAt < 0 + || now - checkedAt >= maxAgeMs + ) return null; + parseVersion(cached.latest); + return cached; + } catch { + return null; + } +} + +async function writeCache(cacheFile, value) { + const temporary = `${cacheFile}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`; + try { + await fs.mkdir(path.dirname(cacheFile), { recursive: true }); + await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + await fs.rename(temporary, cacheFile); + } catch { + await fs.rm(temporary, { force: true }).catch(() => {}); + } +} + +function normalizeRegistry(registry) { + let parsed; + try { + parsed = new URL(registry || DEFAULT_REGISTRY); + } catch { + throw new Error(`Invalid npm registry URL '${registry}'.`); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error(`Unsupported npm registry protocol '${parsed.protocol}'.`); + } + return parsed.href.replace(/\/+$/, ""); +} + +export async function checkForUpdate({ + cacheFile = getUpdateCacheFile(), + currentVersion = VERSION, + fetchImpl = globalThis.fetch, + force = false, + maxAgeMs = UPDATE_CHECK_INTERVAL_MS, + now = Date.now(), + registry = process.env.npm_config_registry || DEFAULT_REGISTRY, + timeoutMs = 5_000, +} = {}) { + parseVersion(currentVersion); + if (!force) { + const cached = await readFreshCache(cacheFile, now, maxAgeMs); + if (cached) { + return { + packageName: PACKAGE_NAME, + current: currentVersion, + latest: cached.latest, + updateAvailable: compareVersions(currentVersion, cached.latest) < 0, + checkedAt: cached.checkedAt, + source: "cache", + }; + } + } + + if (typeof fetchImpl !== "function") throw new Error("This runtime does not provide fetch()."); + const registryBase = normalizeRegistry(registry); + const packagePath = PACKAGE_NAME.replace("/", "%2F"); + const response = await fetchImpl(`${registryBase}/${packagePath}/latest`, { + headers: { accept: "application/json", "user-agent": `codedrobe/${currentVersion}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}.`); + const manifest = await response.json(); + if (!manifest || typeof manifest.version !== "string") throw new Error("npm registry response does not contain a version."); + parseVersion(manifest.version); + + const checkedAt = new Date(now).toISOString(); + await writeCache(cacheFile, { schemaVersion: 1, packageName: PACKAGE_NAME, latest: manifest.version, checkedAt }); + return { + packageName: PACKAGE_NAME, + current: currentVersion, + latest: manifest.version, + updateAvailable: compareVersions(currentVersion, manifest.version) < 0, + checkedAt, + source: "registry", + }; +} + +export function detectPackageManager({ env = process.env, packageRoot = PACKAGE_ROOT, versions = process.versions } = {}) { + const normalizedRoot = packageRoot.replaceAll("\\", "/").toLowerCase(); + const userAgent = String(env.npm_config_user_agent || "").toLowerCase(); + if (normalizedRoot.includes("/.bun/install/global/")) return "bun"; + if (normalizedRoot.includes("/pnpm/global/") || normalizedRoot.includes("/.pnpm-global/")) return "pnpm"; + if (normalizedRoot.includes("/lib/node_modules/@codedrobe/core") || normalizedRoot.includes("/npm/node_modules/@codedrobe/core")) return "npm"; + if (userAgent.startsWith("bun/") || versions.bun) return "bun"; + if (userAgent.startsWith("pnpm/")) return "pnpm"; + return "npm"; +} + +export function getUpdateCommand(packageManager = detectPackageManager()) { + if (packageManager === "bun") return { command: "bun", args: ["add", "--global", `${PACKAGE_NAME}@latest`] }; + if (packageManager === "pnpm") return { command: "pnpm", args: ["add", "--global", `${PACKAGE_NAME}@latest`] }; + return { command: "npm", args: ["install", "--global", `${PACKAGE_NAME}@latest`] }; +} + +export function formatCommand({ command, args }) { + return [command, ...args].join(" "); +} + +async function runPackageManager(command, args, { quiet = false } = {}) { + await new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: quiet ? "ignore" : "inherit", shell: false }); + child.once("error", (error) => reject(new Error(`Unable to run ${command}: ${error.message}`))); + child.once("close", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} update failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}.`)); + }); + }); +} + +export async function updateCodeDrobe({ + checkOptions = {}, + packageManager = detectPackageManager(), + quiet = false, + runner = runPackageManager, +} = {}) { + const status = await checkForUpdate({ ...checkOptions, force: true }); + const updateCommand = getUpdateCommand(packageManager); + if (!status.updateAvailable) return { ...status, packageManager, command: formatCommand(updateCommand), updated: false }; + await runner(updateCommand.command, updateCommand.args, { quiet }); + return { ...status, packageManager, command: formatCommand(updateCommand), updated: true }; +} + +function environmentFlag(value) { + return value !== undefined && value !== "" && value !== "0" && value.toLowerCase?.() !== "false"; +} + +export function isGlobalInstallation(packageRoot = PACKAGE_ROOT) { + const normalized = packageRoot.replaceAll("\\", "/").toLowerCase(); + return normalized.includes("/lib/node_modules/@codedrobe/core") + || normalized.includes("/npm/node_modules/@codedrobe/core") + || normalized.includes("/.bun/install/global/") + || normalized.includes("/pnpm/global/") + || normalized.includes("/.pnpm-global/"); +} + +export function shouldNotifyUpdate({ + command, + env = process.env, + json = false, + packageRoot = PACKAGE_ROOT, + stderrIsTTY = process.stderr.isTTY, +} = {}) { + if (!stderrIsTTY || json || !isGlobalInstallation(packageRoot)) return false; + if (["help", "version", "update"].includes(command)) return false; + if (environmentFlag(env.CI) || environmentFlag(env.NO_UPDATE_NOTIFIER) || environmentFlag(env.CODEDROBE_DISABLE_UPDATE_CHECK)) return false; + return true; +} + +export async function maybeNotifyUpdate({ + checkOptions = {}, + command, + env = process.env, + json = false, + packageRoot = PACKAGE_ROOT, + stderr = console.error, + stderrIsTTY = process.stderr.isTTY, +} = {}) { + if (!shouldNotifyUpdate({ command, env, json, packageRoot, stderrIsTTY })) return null; + try { + const status = await checkForUpdate({ timeoutMs: 1_500, ...checkOptions }); + if (status.updateAvailable) { + const updateCommand = getUpdateCommand(detectPackageManager({ env, packageRoot })); + stderr(`[codedrobe] Update available ${status.current} → ${status.latest}. Run: ${formatCommand(updateCommand)}`); + } + return status; + } catch { + return null; + } +} diff --git a/src/version.mjs b/src/version.mjs index 6fa18df..edbab61 100644 --- a/src/version.mjs +++ b/src/version.mjs @@ -1 +1 @@ -export const VERSION = "0.1.1"; +export const VERSION = "0.2.0"; diff --git a/tests/adapters.test.mjs b/tests/adapters.test.mjs index 8eb3363..3d19f47 100644 --- a/tests/adapters.test.mjs +++ b/tests/adapters.test.mjs @@ -15,11 +15,42 @@ test("Codex target matcher accepts only app pages", () => { assert.equal(adapter.matchTarget({ type: "worker", url: "app://codex/worker" }), false); }); -test("WorkBuddy target matcher accepts local renderer schemes and rejects DevTools", () => { +test("Codex verification keeps only current cross-route landmarks", () => { + const adapter = getAdapter("codex"); + assert.deepEqual(adapter.lastVerified.darwin, { + appVersion: "26.707.72221", + build: "5307", + verifiedAt: "2026-07-16", + }); + assert.deepEqual(adapter.verification.rootAny, ["main.main-surface"]); + assert.deepEqual(adapter.verification.required, [ + { name: "sidebar", any: ["aside.app-shell-left-panel"] }, + { name: "composer", any: [".composer-surface-chrome"] }, + ]); + assert.doesNotMatch(JSON.stringify(adapter.verification), /"main"|"aside"|contenteditable|textarea/); +}); + +test("WorkBuddy target matcher accepts its actual local renderer and rejects unrelated pages", () => { const adapter = getAdapter("workbuddy"); + assert.equal(adapter.matchTarget({ + type: "page", + title: "WorkBuddy", + url: "file:///Applications/WorkBuddy.app/Contents/Resources/app.asar/renderer/index.html", + }), true); assert.equal(adapter.matchTarget({ type: "page", url: "vscode-file://workbench/index.html" }), true); assert.equal(adapter.matchTarget({ type: "page", url: "workbuddy://desktop/home" }), true); assert.equal(adapter.matchTarget({ type: "page", url: "devtools://devtools/bundled/inspector.html", title: "WorkBuddy" }), false); + assert.equal(adapter.matchTarget({ type: "page", url: "file:///tmp/unrelated/index.html" }), false); +}); + +test("WorkBuddy verification uses selectors observed in the real renderer", () => { + const adapter = getAdapter("workbuddy"); + assert.deepEqual(adapter.lastVerified.darwin, { appVersion: "5.2.6", build: "5.2.6", verifiedAt: "2026-07-16" }); + assert.match(adapter.verification.rootAny.join(" "), /teams-container/); + assert.match(adapter.verification.required.find((item) => item.name === "sidebar").any.join(" "), /conversation-sidebar/); + assert.match(adapter.verification.required.find((item) => item.name === "workspace").any.join(" "), /teams-main-content/); + assert.match(adapter.verification.required.find((item) => item.name === "composer").any.join(" "), /role='textbox'/); + assert.doesNotMatch(JSON.stringify(adapter.verification), /monaco-workbench/); }); test("adapter registration validates and prevents duplicate ids", () => { diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs new file mode 100644 index 0000000..0e5e6a4 --- /dev/null +++ b/tests/cli.test.mjs @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { HELP, runCli } from "../src/cli.mjs"; + +test("probe documents and validates its configurable timeout", async () => { + assert.match(HELP, /probe.+--timeout-ms /); + await assert.rejects( + runCli(["probe", "--app", "workbuddy", "--timeout-ms", "100"]), + /integer from 250 to 300000 milliseconds/, + ); +}); diff --git a/tests/injector.test.mjs b/tests/injector.test.mjs new file mode 100644 index 0000000..e874b00 --- /dev/null +++ b/tests/injector.test.mjs @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { getAdapter } from "../src/adapters/index.mjs"; +import { waitForTargets } from "../src/runtime/injector.mjs"; + +test("target wait respects a short timeout and returns structured diagnostics", async (t) => { + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end("[]"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + const address = server.address(); + const startedAt = Date.now(); + + await assert.rejects( + waitForTargets(getAdapter("workbuddy"), address.port, 300), + (error) => { + assert.equal(error.code, "CODEDROBE_TARGET_TIMEOUT"); + assert.equal(error.port, address.port); + assert.equal(error.timeoutMs, 300); + assert.match(error.message, /within 300ms/); + return true; + }, + ); + assert.ok(Date.now() - startedAt < 1000); +}); diff --git a/tests/launcher.test.mjs b/tests/launcher.test.mjs new file mode 100644 index 0000000..2143d06 --- /dev/null +++ b/tests/launcher.test.mjs @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { getAdapter } from "../src/adapters/index.mjs"; +import { discoverApp } from "../src/runtime/launcher.mjs"; + +test("custom app path accepts a macOS app bundle or executable", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-app-path-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + + const appPath = path.join(directory, "Custom WorkBuddy.app"); + const executable = path.join(appPath, "Contents", "MacOS", "Electron"); + await fs.mkdir(path.dirname(executable), { recursive: true }); + await fs.writeFile(executable, "test executable"); + + const adapter = getAdapter("workbuddy"); + assert.deepEqual(await discoverApp(adapter, "darwin", appPath), { + appId: "workbuddy", + appPath, + executable, + }); + assert.deepEqual(await discoverApp(adapter, "darwin", executable), { + appId: "workbuddy", + appPath: path.dirname(executable), + executable, + }); +}); + +test("custom app path does not fall back to default discovery", async () => { + const adapter = getAdapter("workbuddy"); + assert.equal(await discoverApp(adapter, "darwin", "/missing/WorkBuddy.app"), null); +}); diff --git a/tests/legacy-theme.test.mjs b/tests/legacy-theme.test.mjs new file mode 100644 index 0000000..4a85317 --- /dev/null +++ b/tests/legacy-theme.test.mjs @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + convertLegacyThemeFile, + convertLegacyThemePackage, + validateLegacyThemePackage, +} from "../src/theme/legacy.mjs"; +import { readThemePackage, resolveThemeTarget } from "../src/theme/package.mjs"; + +function legacyTheme(overrides = {}) { + return { + format: "codex-theme", + schemaVersion: 1, + exportedAt: "2026-07-15T00:00:00.000Z", + manifest: { + schemaVersion: 1, + id: "legacy-dream", + displayName: "Legacy Dream", + version: "1.2.3", + css: "theme.css", + art: "cover.png", + copy: { tagline: "Converted safely" }, + baseTheme: { mode: "light", accent: "#b65cff" }, + }, + css: ":root { color: #432; }", + art: { filename: "cover.png", mimeType: "image/png", base64: "aGVsbG8=" }, + ...overrides, + }; +} + +test("converts a legacy Codex package into a single-target CodeDrobe package", () => { + const converted = convertLegacyThemePackage(legacyTheme()); + assert.equal(converted.format, "codedrobe-theme"); + assert.deepEqual(Object.keys(converted.targets), ["codex"]); + assert.equal(converted.theme.copy.tagline, "Converted safely"); + assert.equal(converted.targets.codex.options.baseTheme.accent, "#b65cff"); + assert.equal(converted.assets.art.filename, "cover.png"); + assert.match(resolveThemeTarget(converted, "codex").css, /#432/); +}); + +test("writes a converted package and keeps overwrite protection", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-legacy-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const input = path.join(directory, "legacy.codex-theme"); + const output = path.join(directory, "legacy.codedrobe-theme"); + await fs.writeFile(input, JSON.stringify(legacyTheme()), "utf8"); + await convertLegacyThemeFile(input, output); + const converted = await readThemePackage(output); + assert.equal(converted.theme.id, "legacy-dream"); + await assert.rejects(() => convertLegacyThemeFile(input, output), /already exists/); +}); + +test("rejects unsafe legacy packages before conversion", () => { + assert.throws(() => validateLegacyThemePackage(legacyTheme({ css: "@import 'https://example.com/x.css';" })), /external CSS/); + const missingArt = legacyTheme(); + delete missingArt.art; + assert.throws(() => validateLegacyThemePackage(missingArt), /artwork.*missing/); + const mismatchedArt = legacyTheme(); + mismatchedArt.art.filename = "other.png"; + assert.throws(() => validateLegacyThemePackage(mismatchedArt), /manifest\.art must match/); + const undeclaredArt = legacyTheme(); + undeclaredArt.manifest.art = null; + assert.throws(() => validateLegacyThemePackage(undeclaredArt), /not declared/); + const executableArt = legacyTheme(); + executableArt.art.mimeType = "image/svg+xml"; + assert.throws(() => validateLegacyThemePackage(executableArt), /MIME type/); +}); diff --git a/tests/renderer-payload.test.mjs b/tests/renderer-payload.test.mjs index ec92164..07f1b08 100644 --- a/tests/renderer-payload.test.mjs +++ b/tests/renderer-payload.test.mjs @@ -1,11 +1,21 @@ import test from "node:test"; import assert from "node:assert/strict"; +import vm from "node:vm"; import { getAdapter } from "../src/adapters/index.mjs"; -import { buildApplyExpression, buildRemoveExpression, buildVerifyExpression } from "../src/runtime/renderer-payload.mjs"; +import { buildApplyExpression, buildProbeExpression, buildRemoveExpression, buildVerifyExpression } from "../src/runtime/renderer-payload.mjs"; const targetTheme = { theme: { id: "dream", displayName: "Dream", version: "1.0.0" }, css: ":root.codedrobe-host-workbuddy { color: #432; }", + verification: { + required: [{ name: "chat-surface", any: [".chat-container", ".wb-cb-chat"] }], + recommended: [{ name: "chat-toolbar", any: [".chat-toolbar"] }], + contexts: [{ + name: "active-chat", + when: { any: [".chat-route"] }, + required: [{ name: "message-list", any: [".message-list"] }], + }], + }, artDataUrl: null, }; @@ -21,5 +31,92 @@ test("renderer payload is namespaced by app and theme", () => { test("remove and verify expressions use the selected adapter", () => { const adapter = getAdapter("codex"); assert.match(buildRemoveExpression(adapter), /codex/); - assert.match(buildVerifyExpression(adapter, targetTheme.theme), /composer/); + assert.match(buildVerifyExpression(adapter, targetTheme.theme, targetTheme.verification), /chat-surface/); +}); + +test("preflight reports missing theme nodes separately from adapter landmarks", () => { + const adapter = getAdapter("workbuddy"); + const visibleElement = { getBoundingClientRect: () => ({ width: 100, height: 40 }) }; + const matchedSelectors = new Set([ + "#root > .teams-container", + ".conversation-sidebar", + ".teams-main-content", + "[role='textbox'][contenteditable='true']", + ]); + const context = { + document: { querySelector: (selector) => matchedSelectors.has(selector) ? visibleElement : null }, + getComputedStyle: () => ({ display: "block", visibility: "visible" }), + innerWidth: 1200, + innerHeight: 800, + }; + + const expression = buildProbeExpression(adapter, targetTheme.verification); + const failed = vm.runInNewContext(expression, context); + assert.equal(failed.compatible, false); + assert.deepEqual( + JSON.parse(JSON.stringify(failed.missing)), + [{ + scope: "theme", + context: null, + severity: "required", + name: "chat-surface", + selectors: [".chat-container", ".wb-cb-chat"], + invalidSelectors: [], + }], + ); + assert.deepEqual(JSON.parse(JSON.stringify(failed.warnings)), [{ + scope: "theme", + context: null, + severity: "recommended", + name: "chat-toolbar", + selectors: [".chat-toolbar"], + invalidSelectors: [], + }]); + assert.equal(failed.contexts[0].active, false); + + matchedSelectors.add(".chat-container"); + const passed = vm.runInNewContext(expression, context); + assert.equal(passed.compatible, true); + assert.deepEqual(JSON.parse(JSON.stringify(passed.missing)), []); + assert.equal(passed.warnings.length, 1); + + matchedSelectors.add(".chat-route"); + const contextualFailure = vm.runInNewContext(expression, context); + assert.equal(contextualFailure.compatible, false); + assert.equal(contextualFailure.contexts[0].active, true); + assert.deepEqual(JSON.parse(JSON.stringify(contextualFailure.missing)), [{ + scope: "theme", + context: "active-chat", + severity: "required", + name: "message-list", + selectors: [".message-list"], + invalidSelectors: [], + }]); + + matchedSelectors.add(".message-list"); + assert.equal(vm.runInNewContext(expression, context).compatible, true); +}); + +test("preflight reports invalid selectors instead of hiding parser failures", () => { + const adapter = getAdapter("codex"); + const visibleElement = { getBoundingClientRect: () => ({ width: 100, height: 40 }) }; + const context = { + document: { + querySelector(selector) { + if (selector === "[broken") throw new Error("Invalid selector"); + return visibleElement; + }, + }, + getComputedStyle: () => ({ display: "block", visibility: "visible" }), + innerWidth: 1200, + innerHeight: 800, + }; + const result = vm.runInNewContext(buildProbeExpression(adapter, { + required: [{ name: "broken-hook", any: ["[broken"] }], + }), context); + assert.equal(result.compatible, false); + assert.deepEqual(JSON.parse(JSON.stringify(result.missing[0].invalidSelectors)), [{ + selector: "[broken", + error: "Invalid selector", + }]); }); diff --git a/tests/theme-package.test.mjs b/tests/theme-package.test.mjs index f29aca3..2454679 100644 --- a/tests/theme-package.test.mjs +++ b/tests/theme-package.test.mjs @@ -6,6 +6,7 @@ import path from "node:path"; import { THEME_EXTENSION, buildThemePackage, + lintThemePackage, readThemePackage, resolveThemeTarget, validateThemePackage, @@ -21,6 +22,9 @@ test("builds one portable theme for multiple app targets", async () => { assert.deepEqual(Object.keys(bundle.targets), ["codex", "workbuddy"]); assert.match(bundle.targets.codex.css, /codedrobe-host-codex/); assert.match(bundle.targets.workbuddy.css, /codedrobe-host-workbuddy/); + assert.equal(bundle.targets.workbuddy.verification.required[0].name, "chat-surface"); + assert.equal(bundle.targets.workbuddy.verification.recommended[0].name, "conversation-list"); + assert.equal(bundle.targets.codex.verification.contexts[0].name, "home"); assert.ok(serialized.endsWith("\n")); }); @@ -31,7 +35,8 @@ test("writes, reads, and resolves a .codedrobe-theme package", async () => { const bundle = await readThemePackage(output); const selected = resolveThemeTarget(bundle, "workbuddy"); assert.equal(selected.theme.version, "1.0.0"); - assert.match(selected.css, /monaco-workbench/); + assert.match(selected.css, /conversation-sidebar/); + assert.equal(selected.verification.required[0].name, "chat-surface"); await assert.rejects(() => writeThemePackage(exampleManifest.pathname, output), /already exists/); }); @@ -46,6 +51,56 @@ test("rejects external CSS resources and executable-looking package variants", ( assert.throws(() => validateThemePackage({ ...base, format: "codex-theme" }), /Unsupported theme format/); }); +test("rejects invalid theme-specific verification nodes", () => { + const base = { + format: "codedrobe-theme", + schemaVersion: 1, + theme: { id: "invalid-probe", displayName: "Invalid Probe", version: "1.0.0" }, + targets: { + workbuddy: { + css: ":root { color: red; }", + verification: { required: [{ name: "composer", any: [] }] }, + }, + }, + }; + assert.throws(() => validateThemePackage(base), /non-empty selector array/); + assert.throws(() => validateThemePackage({ + ...base, + targets: { + workbuddy: { + css: ":root { color: red; }", + verification: { + contexts: [{ name: "home", when: { any: [".home"] } }], + }, + }, + }, + }), /must declare required or recommended checks/); +}); + +test("accepts recommended-only checks and reports brittle selector warnings", () => { + const bundle = { + format: "codedrobe-theme", + schemaVersion: 1, + theme: { id: "lint-probe", displayName: "Lint Probe", version: "1.0.0" }, + targets: { + codex: { + css: ".shell > div > div > div:first-child { color: red; }", + verification: { + recommended: [{ name: "localized-button", any: ["button[aria-label='切换模式']"] }], + }, + }, + }, + }; + assert.equal(validateThemePackage(bundle), bundle); + const warnings = lintThemePackage(bundle); + assert.deepEqual(new Set(warnings.map((warning) => warning.code)), new Set([ + "positional-selector", + "deep-child-chain", + "localized-attribute", + ])); + assert.ok(warnings.every((warning) => warning.appId === "codex")); +}); + test("rejects a theme that does not support the selected app", async () => { const { bundle } = await buildThemePackage(exampleManifest.pathname); assert.throws(() => resolveThemeTarget(bundle, "unknown-ai"), /does not support app/); diff --git a/tests/types/consumer.ts b/tests/types/consumer.ts new file mode 100644 index 0000000..45fe550 --- /dev/null +++ b/tests/types/consumer.ts @@ -0,0 +1,40 @@ +import { + convertLegacyThemePackage, + getAdapter, + launchApp, + probeApp, + type LegacyThemePackage, + type ThemePackage, +} from "@codedrobe/core"; +import { + convertLegacyThemePackage as convertLegacyFromThemeEntry, + resolveThemeTarget, + type ThemeLintWarning, +} from "@codedrobe/core/theme"; +import { listAdapters, type AppAdapter } from "@codedrobe/core/adapters"; + +const adapter: AppAdapter = getAdapter("codex"); +const adapters: AppAdapter[] = listAdapters(); +void adapters; + +const legacy: LegacyThemePackage = { + format: "codex-theme", + schemaVersion: 1, + manifest: { + schemaVersion: 1, + id: "typed", + displayName: "Typed", + version: "1.0.0", + css: "theme.css", + }, + css: ":root { color: red; }", +}; + +const converted: ThemePackage = convertLegacyThemePackage(legacy); +const convertedFromThemeEntry: ThemePackage = convertLegacyFromThemeEntry(legacy); +void convertedFromThemeEntry; +const target = resolveThemeTarget(converted, adapter.id); +const warnings: ThemeLintWarning[] = []; +void warnings; +void launchApp({ adapter, port: 9444, appPath: "/Applications/ChatGPT.app" }); +void probeApp({ adapter, port: 9444, targetTheme: target, timeoutMs: 5000 }); diff --git a/tests/types/tsconfig.json b/tests/types/tsconfig.json new file mode 100644 index 0000000..417831c --- /dev/null +++ b/tests/types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "skipLibCheck": false + }, + "include": ["consumer.ts"] +} diff --git a/tests/update.test.mjs b/tests/update.test.mjs new file mode 100644 index 0000000..13b2e68 --- /dev/null +++ b/tests/update.test.mjs @@ -0,0 +1,126 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + checkForUpdate, + compareVersions, + detectPackageManager, + formatCommand, + getUpdateCommand, + maybeNotifyUpdate, + shouldNotifyUpdate, + updateCodeDrobe, +} from "../src/update.mjs"; + +test("compares stable and prerelease semantic versions", () => { + assert.equal(compareVersions("0.1.1", "0.2.0"), -1); + assert.equal(compareVersions("1.0.0", "1.0.0"), 0); + assert.equal(compareVersions("2.0.0", "1.9.9"), 1); + assert.equal(compareVersions("1.0.0-beta.2", "1.0.0-beta.10"), -1); + assert.equal(compareVersions("1.0.0-rc.1", "1.0.0"), -1); +}); + +test("checks the npm registry and reuses a fresh cache", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-update-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const cacheFile = path.join(directory, "update.json"); + let requests = 0; + const fetchImpl = async (url) => { + requests += 1; + assert.match(url, /@codedrobe%2Fcore\/latest$/); + return { ok: true, json: async () => ({ version: "0.2.0" }) }; + }; + const now = Date.parse("2026-07-16T10:00:00.000Z"); + + const registry = await checkForUpdate({ cacheFile, currentVersion: "0.1.1", fetchImpl, now }); + assert.deepEqual(registry, { + packageName: "@codedrobe/core", + current: "0.1.1", + latest: "0.2.0", + updateAvailable: true, + checkedAt: "2026-07-16T10:00:00.000Z", + source: "registry", + }); + + const cached = await checkForUpdate({ cacheFile, currentVersion: "0.1.1", fetchImpl, now: now + 60_000 }); + assert.equal(cached.source, "cache"); + assert.equal(requests, 1); +}); + +test("selects npm, Bun, and pnpm global update commands", () => { + assert.equal(detectPackageManager({ packageRoot: "/usr/local/lib/node_modules/@codedrobe/core", env: {}, versions: {} }), "npm"); + assert.equal(detectPackageManager({ packageRoot: "/usr/local/lib/node_modules/@codedrobe/core", env: {}, versions: { bun: "1.3.11" } }), "npm"); + assert.equal(detectPackageManager({ packageRoot: "/Users/me/.bun/install/global/node_modules/@codedrobe/core", env: {}, versions: {} }), "bun"); + assert.equal(detectPackageManager({ packageRoot: "/Users/me/pnpm/global/5/node_modules/@codedrobe/core", env: {}, versions: {} }), "pnpm"); + assert.equal(formatCommand(getUpdateCommand("npm")), "npm install --global @codedrobe/core@latest"); + assert.equal(formatCommand(getUpdateCommand("bun")), "bun add --global @codedrobe/core@latest"); + assert.equal(formatCommand(getUpdateCommand("pnpm")), "pnpm add --global @codedrobe/core@latest"); +}); + +test("explicit update runs the selected package manager only when a newer version exists", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-self-update-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const calls = []; + const result = await updateCodeDrobe({ + packageManager: "bun", + runner: async (...args) => calls.push(args), + checkOptions: { + cacheFile: path.join(directory, "update.json"), + currentVersion: "0.1.1", + fetchImpl: async () => ({ ok: true, json: async () => ({ version: "0.2.0" }) }), + }, + }); + assert.equal(result.updated, true); + assert.deepEqual(calls, [["bun", ["add", "--global", "@codedrobe/core@latest"], { quiet: false }]]); +}); + +test("automatic notifications are limited to interactive global installations", () => { + const base = { + command: "apps", + env: {}, + packageRoot: "/usr/local/lib/node_modules/@codedrobe/core", + stderrIsTTY: true, + }; + assert.equal(shouldNotifyUpdate(base), true); + assert.equal(shouldNotifyUpdate({ ...base, json: true }), false); + assert.equal(shouldNotifyUpdate({ ...base, env: { CI: "1" } }), false); + assert.equal(shouldNotifyUpdate({ ...base, env: { NO_UPDATE_NOTIFIER: "1" } }), false); + assert.equal(shouldNotifyUpdate({ ...base, command: "update" }), false); + assert.equal(shouldNotifyUpdate({ ...base, packageRoot: "/project/node_modules/@codedrobe/core" }), false); + assert.equal(shouldNotifyUpdate({ ...base, stderrIsTTY: false }), false); +}); + +test("automatic notification prints a cached update hint and ignores check failures", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-notifier-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const messages = []; + const base = { + command: "apps", + env: {}, + packageRoot: "/usr/local/lib/node_modules/@codedrobe/core", + stderr: (message) => messages.push(message), + stderrIsTTY: true, + }; + const status = await maybeNotifyUpdate({ + ...base, + checkOptions: { + cacheFile: path.join(directory, "update.json"), + currentVersion: "0.1.1", + fetchImpl: async () => ({ ok: true, json: async () => ({ version: "0.2.0" }) }), + }, + }); + assert.equal(status.updateAvailable, true); + assert.deepEqual(messages, ["[codedrobe] Update available 0.1.1 → 0.2.0. Run: npm install --global @codedrobe/core@latest"]); + + const failed = await maybeNotifyUpdate({ + ...base, + checkOptions: { + cacheFile: path.join(directory, "missing.json"), + fetchImpl: async () => { throw new Error("offline"); }, + }, + }); + assert.equal(failed, null); + assert.equal(messages.length, 1); +}); diff --git a/types/adapters.d.ts b/types/adapters.d.ts new file mode 100644 index 0000000..0c2be93 --- /dev/null +++ b/types/adapters.d.ts @@ -0,0 +1,11 @@ +export { getAdapter, listAdapters, registerAdapter } from "./index.js"; +export type { + AdapterPlatformConfig, + AdapterVerificationRecord, + AppAdapter, + CdpTarget, + SupportedPlatform, + VerificationContext, + VerificationProfile, + VerificationRequirement, +} from "./index.js"; diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..563a3bf --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,258 @@ +export type SupportedPlatform = "darwin" | "win32"; + +export interface CdpTarget { + id: string; + type?: string; + title?: string; + url?: string; + webSocketDebuggerUrl: string; + [key: string]: unknown; +} + +export interface VerificationRequirement { + name: string; + any: string[]; +} + +export interface VerificationContext { + name: string; + when: { any: string[] }; + required?: VerificationRequirement[]; + recommended?: VerificationRequirement[]; +} + +export interface VerificationProfile { + rootAny?: string[]; + required?: VerificationRequirement[]; + recommended?: VerificationRequirement[]; + contexts?: VerificationContext[]; +} + +export interface AdapterPlatformConfig { + bundleId?: string; + appCandidates?: string[]; + appxPackage?: string; + executableRelative?: string; + executableCandidates?: string[]; + processMarkers?: string[]; + processNames?: string[]; +} + +export interface AdapterVerificationRecord { + appVersion: string; + build?: string; + verifiedAt: string; +} + +export interface AppAdapter { + id: string; + displayName: string; + defaultPort: number; + platforms: Partial> & Record; + lastVerified?: Partial>; + verification?: VerificationProfile; + matchTarget(target: CdpTarget): boolean; +} + +export interface ThemeIdentity { + id: string; + displayName: string; + version: string; + copy?: Record; +} + +export interface ThemeArt { + filename: string; + mimeType: string; + base64: string; +} + +export interface ThemeTarget { + css: string; + options?: Record; + verification?: VerificationProfile; +} + +export interface ThemePackage { + format: "codedrobe-theme"; + schemaVersion: 1; + exportedAt?: string; + theme: ThemeIdentity; + targets: Record; + assets?: { art?: ThemeArt }; +} + +export interface LegacyThemeManifest extends ThemeIdentity { + schemaVersion: 1; + css: string; + art?: string | null; + baseTheme?: Record; +} + +export interface LegacyThemePackage { + format: "codex-theme"; + schemaVersion: 1; + exportedAt?: string; + manifest: LegacyThemeManifest; + css: string; + art?: ThemeArt; +} + +export type LegacyThemeArt = ThemeArt; + +export interface ConvertLegacyThemeFileResult { + input: string; + output: string; + bundle: ThemePackage; +} + +export interface ResolvedThemeTarget { + theme: ThemeIdentity; + css: string; + options: Record; + verification: VerificationProfile | null; + artDataUrl: string | null; +} + +export interface SelectorParseError { + selector: string; + error: string; +} + +export interface CompatibilityIssue { + scope: "adapter" | "theme"; + context: string | null; + severity: "required" | "recommended"; + name: string; + selectors: string[]; + invalidSelectors: SelectorParseError[]; +} + +export interface CompatibilityRequirement extends CompatibilityIssue { + pass: boolean; + matches: string[]; +} + +export interface CompatibilityContextResult { + scope: "adapter" | "theme"; + name: string; + active: boolean; + matches: string[]; + selectors: string[]; + invalidSelectors: SelectorParseError[]; +} + +export interface CompatibilityResult { + appId: string; + compatible: boolean; + rootMatches: string[]; + rootInvalidSelectors: SelectorParseError[]; + contexts: CompatibilityContextResult[]; + requirements: CompatibilityRequirement[]; + missing: CompatibilityIssue[]; + warnings: CompatibilityIssue[]; + viewport: { width: number; height: number }; + installed?: boolean; + themeId?: string | null; + version?: string | null; + stylePresent?: boolean; + horizontalOverflow?: boolean; + pass?: boolean; +} + +export interface TargetResult { + targetId: string; + title?: string; + url?: string; + result: T; +} + +export interface AppInstallation { + appId: string; + appPath: string; + executable: string; +} + +export interface LaunchOptions { + adapter: AppAdapter; + port?: number; + appPath?: string | null; + profilePath?: string | null; + restartExisting?: boolean; + timeoutMs?: number; +} + +export interface LaunchResult { + appId: string; + port: number; + alreadyReady?: boolean; + executable?: string; + pid?: number; + targets: number; +} + +export interface ThemeRuntimeOptions { + adapter: AppAdapter; + targetTheme?: ResolvedThemeTarget | null; + port: number; + timeoutMs?: number; +} + +export interface ThemeLintWarning { + code: string; + appId: string; + location: string; + selector: string; + message: string; +} + +export class CdpSession { + constructor(target: CdpTarget, timeoutMs?: number); + readonly target: CdpTarget; + readonly timeoutMs: number; + readonly closed: boolean; + open(): Promise; + on(method: string, listener: (params: Record) => void): void; + send(method: string, params?: Record): Promise; + evaluate(expression: string): Promise; + close(): void; +} + +export const VERSION: string; +export const THEME_FORMAT: "codedrobe-theme"; +export const THEME_EXTENSION: ".codedrobe-theme"; +export const THEME_SCHEMA_VERSION: 1; +export const MAX_THEME_PACKAGE_BYTES: number; +export const LEGACY_THEME_FORMAT: "codex-theme"; +export const LEGACY_THEME_EXTENSION: ".codex-theme"; +export const LEGACY_THEME_SCHEMA_VERSION: 1; + +export function listAdapters(): AppAdapter[]; +export function getAdapter(id: string): AppAdapter; +export function registerAdapter(adapter: AppAdapter): void; +export function listCdpTargets(port: number, timeoutMs?: number): Promise; +export function discoverApp(adapter: AppAdapter, platform?: string, appPath?: string | null): Promise; +export function findRunningPids(adapter: AppAdapter, platform?: string, executablePath?: string | null): Promise; +export function launchApp(options: LaunchOptions): Promise; +export function findTargets(adapter: AppAdapter, port: number, timeoutMs?: number): Promise; +export function waitForTargets(adapter: AppAdapter, port: number, timeoutMs?: number): Promise; +export function probeApp(options: ThemeRuntimeOptions): Promise>>; +export function applyTheme(options: ThemeRuntimeOptions & { targetTheme: ResolvedThemeTarget }): Promise>>; +export function verifyTheme(options: ThemeRuntimeOptions): Promise>>; +export function removeTheme(options: Omit): Promise>>; +export function captureScreenshot(options: Omit & { output: string }): Promise; +export function watchTheme(options: ThemeRuntimeOptions & { + targetTheme: ResolvedThemeTarget; + onEvent?: (event: Record) => void; +}): Promise; + +export function validateThemePackage(bundle: unknown): ThemePackage; +export function readThemePackage(filename: string): Promise; +export function resolveThemeTarget(bundle: ThemePackage, appId: string): ResolvedThemeTarget; +export function lintThemePackage(bundle: ThemePackage): ThemeLintWarning[]; +export function buildThemePackage(manifestFilename: string): Promise<{ bundle: ThemePackage; serialized: string }>; +export function writeThemePackage(manifestFilename: string, outputFilename: string, options?: { force?: boolean }): Promise<{ output: string; bundle: ThemePackage }>; +export function validateLegacyThemePackage(bundle: unknown): LegacyThemePackage; +export function readLegacyThemePackage(filename: string): Promise; +export function convertLegacyThemePackage(bundle: LegacyThemePackage): ThemePackage; +export function convertLegacyThemeFile(inputFilename: string, outputFilename: string, options?: { force?: boolean }): Promise; diff --git a/types/theme.d.ts b/types/theme.d.ts new file mode 100644 index 0000000..f47774a --- /dev/null +++ b/types/theme.d.ts @@ -0,0 +1,34 @@ +export { + MAX_THEME_PACKAGE_BYTES, + THEME_EXTENSION, + THEME_FORMAT, + THEME_SCHEMA_VERSION, + LEGACY_THEME_EXTENSION, + LEGACY_THEME_FORMAT, + LEGACY_THEME_SCHEMA_VERSION, + buildThemePackage, + convertLegacyThemeFile, + convertLegacyThemePackage, + lintThemePackage, + readLegacyThemePackage, + readThemePackage, + resolveThemeTarget, + validateLegacyThemePackage, + validateThemePackage, + writeThemePackage, +} from "./index.js"; +export type { + ConvertLegacyThemeFileResult, + LegacyThemeArt, + LegacyThemeManifest, + LegacyThemePackage, + ResolvedThemeTarget, + ThemeArt, + ThemeIdentity, + ThemeLintWarning, + ThemePackage, + ThemeTarget, + VerificationContext, + VerificationProfile, + VerificationRequirement, +} from "./index.js";