minecodex 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/features/hide-upsell-banner/README.md +27 -0
- package/features/hide-upsell-banner/codex-feature.json +24 -0
- package/features/hide-upsell-banner/src/hide-upsell-banner.mjs +60 -0
- package/features/images/codex-feature.json +1 -1
- package/features/model-slider/codex-feature.json +1 -1
- package/features/notes/codex-feature.json +1 -1
- package/package.json +4 -2
- package/packages/cli/src/config.mjs +2 -1
- package/packages/cli/src/control-server.mjs +7 -0
- package/packages/runtime-host/src/codex-runtime.mjs +64 -9
- package/packages/runtime-host/src/feature-registry.mjs +26 -3
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Hide Upsell Banner
|
|
2
|
+
|
|
3
|
+
隐藏 Codex 桌面应用在输入区上方渲染的
|
|
4
|
+
“You're out of Codex and Work usage”用量耗尽横幅(upsell banner)。
|
|
5
|
+
|
|
6
|
+
这是一个 `page-script` 类型的轻量功能:不启动 HTTP 服务、没有 Sidebar 或
|
|
7
|
+
Toolbar 入口,由 `CodexRuntimeHost` 在渲染进程内注入一段脚本。
|
|
8
|
+
|
|
9
|
+
功能范围:
|
|
10
|
+
|
|
11
|
+
- 仅隐藏横幅的 GUI 显示(`aside` 卡片,`display: none`);
|
|
12
|
+
- 不拦截、不篡改额度查询接口(`account/rateLimits/read` 与 app-server
|
|
13
|
+
JSON-RPC 流量保持原样);
|
|
14
|
+
- 横幅重新渲染时由 `MutationObserver` 持续处理,不会闪烁后残留;
|
|
15
|
+
- 注入运行时随 RuntimeHost 卸载自动清理(恢复元素样式、断开 observer)。
|
|
16
|
+
|
|
17
|
+
## 验证
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm test
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 适配说明
|
|
24
|
+
|
|
25
|
+
这是非官方 CDP / DOM Adapter 的功能声明。Codex 私有 DOM 的适配实现统一留在
|
|
26
|
+
`CodexRuntimeHost`;本功能只声明横幅的识别规则(卡片类名 + 文案特征)与
|
|
27
|
+
隐藏动作。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"id": "hide-upsell-banner",
|
|
4
|
+
"version": "0.1.10",
|
|
5
|
+
"label": {
|
|
6
|
+
"en": "Hide Upsell Banner",
|
|
7
|
+
"zh-CN": "隐藏用量横幅"
|
|
8
|
+
},
|
|
9
|
+
"description": {
|
|
10
|
+
"en": "Hide the rate-limit upsell banner in the composer while leaving usage checks untouched.",
|
|
11
|
+
"zh-CN": "隐藏输入区上方的用量耗尽横幅,不拦截额度查询功能。"
|
|
12
|
+
},
|
|
13
|
+
"entry": {
|
|
14
|
+
"kind": "page-script",
|
|
15
|
+
"script": "src/hide-upsell-banner.mjs"
|
|
16
|
+
},
|
|
17
|
+
"icon": {
|
|
18
|
+
"viewBox": "0 0 24 24",
|
|
19
|
+
"markup": "<rect width=\"18\" height=\"12\" x=\"3\" y=\"6\" rx=\"2.5\"></rect><path d=\"m5 5 14 14\"></path>"
|
|
20
|
+
},
|
|
21
|
+
"placement": {
|
|
22
|
+
"order": 40
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// page-script feature body.
|
|
2
|
+
// Injected by CodexRuntimeHost into the Codex renderer through
|
|
3
|
+
// `new Function("signal", "window", "document", "MutationObserver", source)`,
|
|
4
|
+
// so this file is a function body: it runs once with those bindings in scope
|
|
5
|
+
// and may return a cleanup function. It hides the "You're out of Codex and
|
|
6
|
+
// Work usage" upsell banner that the desktop app renders above the composer
|
|
7
|
+
// when the account rate-limit snapshot marks the plan as exhausted. It never
|
|
8
|
+
// touches the rate-limit query path: `account/rateLimits/read` and the
|
|
9
|
+
// app-server JSON-RPC traffic stay intact.
|
|
10
|
+
const HIDDEN_MARKER = "data-codex-upsell-banner-hidden";
|
|
11
|
+
|
|
12
|
+
function isUpsellBanner(element) {
|
|
13
|
+
if (!(element instanceof window.HTMLElement)) return false;
|
|
14
|
+
const text = (element.innerText ?? "").trim();
|
|
15
|
+
if (!text) return false;
|
|
16
|
+
const isCard = element.classList.contains("rounded-2xl")
|
|
17
|
+
&& element.classList.contains("border")
|
|
18
|
+
&& element.classList.contains("bg-token-main-surface-primary");
|
|
19
|
+
if (!isCard) return false;
|
|
20
|
+
const titleMatch = /out of Codex and Work usage/i.test(text)
|
|
21
|
+
|| /out of Codex messages/i.test(text)
|
|
22
|
+
|| /Codex message usage limit reached/i.test(text);
|
|
23
|
+
const actionMatch = /Upgrade to Pro|Add Credits/i.test(text);
|
|
24
|
+
return titleMatch && actionMatch;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hideBanner(element) {
|
|
28
|
+
if (element.hasAttribute(HIDDEN_MARKER)) return;
|
|
29
|
+
element.setAttribute(HIDDEN_MARKER, "");
|
|
30
|
+
element.style.display = "none";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hideVisibleBanners(root = document) {
|
|
34
|
+
if (root.matches?.("aside") && isUpsellBanner(root)) hideBanner(root);
|
|
35
|
+
for (const element of root.querySelectorAll("aside")) {
|
|
36
|
+
if (isUpsellBanner(element)) hideBanner(element);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
hideVisibleBanners();
|
|
41
|
+
const observer = new MutationObserver((records) => {
|
|
42
|
+
for (const record of records) {
|
|
43
|
+
for (const node of record.addedNodes) {
|
|
44
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
45
|
+
hideVisibleBanners(node);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
observer.observe(document.documentElement, {
|
|
50
|
+
childList: true,
|
|
51
|
+
subtree: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
observer.disconnect();
|
|
56
|
+
for (const element of document.querySelectorAll(`[${HIDDEN_MARKER}]`)) {
|
|
57
|
+
element.removeAttribute(HIDDEN_MARKER);
|
|
58
|
+
element.style.display = "";
|
|
59
|
+
}
|
|
60
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "minecodex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Lightweight, local-first plugins for the Codex desktop app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"features/images/src",
|
|
30
30
|
"features/images/web",
|
|
31
31
|
"features/images/codex-feature.json",
|
|
32
|
+
"features/hide-upsell-banner/src",
|
|
33
|
+
"features/hide-upsell-banner/codex-feature.json",
|
|
32
34
|
"apps/dashboard/app.js",
|
|
33
35
|
"apps/dashboard/favicon.svg",
|
|
34
36
|
"apps/dashboard/index.html",
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
},
|
|
41
43
|
"scripts": {
|
|
42
44
|
"start": "node packages/cli/bin/mcx.mjs serve",
|
|
43
|
-
"test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs apps/dashboard/test/*.test.mjs",
|
|
45
|
+
"test": "node --test packages/cli/test/*.test.mjs packages/runtime-host/test/*.test.mjs features/model-slider/test/*.test.mjs features/notes/test/*.test.mjs features/images/test/*.test.mjs features/hide-upsell-banner/test/*.test.mjs apps/dashboard/test/*.test.mjs",
|
|
44
46
|
"test:cli": "node --test packages/cli/test/*.test.mjs",
|
|
45
47
|
"test:runtime": "node --test packages/runtime-host/test/*.test.mjs",
|
|
46
48
|
"check": "node --check packages/cli/bin/mcx.mjs && node --check packages/cli/src/*.mjs && node --check packages/runtime-host/src/*.mjs",
|
|
@@ -2,13 +2,14 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images"]);
|
|
5
|
+
export const FEATURE_IDS = Object.freeze(["model-slider", "notes", "images", "hide-upsell-banner"]);
|
|
6
6
|
export const DEFAULT_CONFIG = Object.freeze({
|
|
7
7
|
schemaVersion: 1,
|
|
8
8
|
features: Object.freeze({
|
|
9
9
|
"model-slider": true,
|
|
10
10
|
notes: true,
|
|
11
11
|
images: true,
|
|
12
|
+
"hide-upsell-banner": true,
|
|
12
13
|
}),
|
|
13
14
|
});
|
|
14
15
|
|
|
@@ -204,6 +204,13 @@ async function iconResponse(id, paths, response) {
|
|
|
204
204
|
response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
|
|
205
205
|
response.end(svg);
|
|
206
206
|
return;
|
|
207
|
+
} else if (id === "hide-upsell-banner") {
|
|
208
|
+
const manifest = JSON.parse(await readFile(path.join(paths.featuresRoot, id, "codex-feature.json"), "utf8"));
|
|
209
|
+
const viewBox = manifest.icon?.viewBox ?? "0 0 24 24";
|
|
210
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${manifest.icon.markup}</svg>`;
|
|
211
|
+
response.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=3600" });
|
|
212
|
+
response.end(svg);
|
|
213
|
+
return;
|
|
207
214
|
} else {
|
|
208
215
|
json(response, 404, { error: "NOT_FOUND" });
|
|
209
216
|
return;
|
|
@@ -11,6 +11,7 @@ const CODEX_APP_ORIGIN = "app://-";
|
|
|
11
11
|
const SURFACE_LOAD_ACTION = "load-surface";
|
|
12
12
|
const SURFACE_FRAME_PREFIX = "minecodex-surface-";
|
|
13
13
|
const LOOPBACK_SURFACE_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
14
|
+
const RIGHT_PANEL_ROUTE_KINDS = Object.freeze(["home", "local-thread"]);
|
|
14
15
|
|
|
15
16
|
export const RESPONSIVE_SUMMARY_LAYOUT = Object.freeze({
|
|
16
17
|
contentBaseWidth: 736,
|
|
@@ -112,6 +113,7 @@ export function createInjectionSource(features, {
|
|
|
112
113
|
currentRuntime?.dispose?.();
|
|
113
114
|
|
|
114
115
|
const lifetime = new AbortController();
|
|
116
|
+
const rightPanelRouteKinds = new Set(config.rightPanelRouteKinds);
|
|
115
117
|
|
|
116
118
|
const entryMarker = "data-codex-personal-feature";
|
|
117
119
|
const pageSurfaceMarker = "data-codex-personal-surface";
|
|
@@ -245,6 +247,7 @@ export function createInjectionSource(features, {
|
|
|
245
247
|
const modelSelectorFeature = features.find(
|
|
246
248
|
(feature) => feature.entry?.kind === "composer-model-selector",
|
|
247
249
|
) ?? null;
|
|
250
|
+
const pageScriptFeatures = features.filter((feature) => feature.entry?.kind === "page-script");
|
|
248
251
|
const pageSurfaces = new Map();
|
|
249
252
|
const pinnedSurfaces = new Map();
|
|
250
253
|
const surfaceRecords = new Map();
|
|
@@ -267,6 +270,7 @@ export function createInjectionSource(features, {
|
|
|
267
270
|
let shiftedThreadOriginal = null;
|
|
268
271
|
let mainContentObserver = null;
|
|
269
272
|
const domObservers = [];
|
|
273
|
+
const pageScriptCleanups = [];
|
|
270
274
|
const pendingHostActions = new Map();
|
|
271
275
|
let activeModalRecordKey = null;
|
|
272
276
|
let activePromptPreview = null;
|
|
@@ -365,6 +369,24 @@ export function createInjectionSource(features, {
|
|
|
365
369
|
void loadNativeFastIcon();
|
|
366
370
|
}
|
|
367
371
|
|
|
372
|
+
for (const feature of pageScriptFeatures) {
|
|
373
|
+
const source = feature.pageScript?.source ?? "";
|
|
374
|
+
if (!source.trim()) continue;
|
|
375
|
+
try {
|
|
376
|
+
const installer = new Function(
|
|
377
|
+
"signal",
|
|
378
|
+
"window",
|
|
379
|
+
"document",
|
|
380
|
+
"MutationObserver",
|
|
381
|
+
`"use strict";\n${source}`,
|
|
382
|
+
);
|
|
383
|
+
const cleanup = installer(lifetime.signal, window, document, MutationObserver);
|
|
384
|
+
if (typeof cleanup === "function") pageScriptCleanups.push(cleanup);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
console.warn(`[codex-personal] page-script feature failed: ${feature.id}`, error);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
368
390
|
function findEntry(featureId) {
|
|
369
391
|
return Array.from(document.querySelectorAll(`[${entryMarker}]`)).find(
|
|
370
392
|
(entry) => entry.getAttribute(entryMarker) === featureId,
|
|
@@ -2701,7 +2723,7 @@ export function createInjectionSource(features, {
|
|
|
2701
2723
|
return key ? element[key] : null;
|
|
2702
2724
|
}
|
|
2703
2725
|
|
|
2704
|
-
function
|
|
2726
|
+
function currentRightPanelScope() {
|
|
2705
2727
|
let anchor = document.querySelector('[data-app-shell-tabs="true"]');
|
|
2706
2728
|
let anchorFiber = reactFiberFor(anchor);
|
|
2707
2729
|
if (!anchorFiber) {
|
|
@@ -2718,12 +2740,12 @@ export function createInjectionSource(features, {
|
|
|
2718
2740
|
let rootFiber = anchorFiber;
|
|
2719
2741
|
while (rootFiber.return) rootFiber = rootFiber.return;
|
|
2720
2742
|
|
|
2721
|
-
const
|
|
2743
|
+
const isRightPanelScope = (value) => (
|
|
2722
2744
|
value
|
|
2723
2745
|
&& typeof value === "object"
|
|
2724
2746
|
&& typeof value.get === "function"
|
|
2725
2747
|
&& typeof value.set === "function"
|
|
2726
|
-
&& value.value?.routeKind
|
|
2748
|
+
&& rightPanelRouteKinds.has(value.value?.routeKind)
|
|
2727
2749
|
&& typeof value.query?.getOrFetch === "function"
|
|
2728
2750
|
);
|
|
2729
2751
|
const inspected = new Set();
|
|
@@ -2734,8 +2756,8 @@ export function createInjectionSource(features, {
|
|
|
2734
2756
|
|| inspected.has(value)
|
|
2735
2757
|
) return null;
|
|
2736
2758
|
inspected.add(value);
|
|
2737
|
-
if (
|
|
2738
|
-
if (
|
|
2759
|
+
if (isRightPanelScope(value)) return value;
|
|
2760
|
+
if (isRightPanelScope(value.current)) return value.current;
|
|
2739
2761
|
return null;
|
|
2740
2762
|
};
|
|
2741
2763
|
|
|
@@ -2769,10 +2791,10 @@ export function createInjectionSource(features, {
|
|
|
2769
2791
|
return null;
|
|
2770
2792
|
}
|
|
2771
2793
|
|
|
2772
|
-
async function
|
|
2794
|
+
async function waitForCurrentRightPanelScope() {
|
|
2773
2795
|
const deadline = performance.now() + 2_000;
|
|
2774
2796
|
do {
|
|
2775
|
-
const scope =
|
|
2797
|
+
const scope = currentRightPanelScope();
|
|
2776
2798
|
if (scope) return scope;
|
|
2777
2799
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2778
2800
|
} while (performance.now() < deadline);
|
|
@@ -2810,6 +2832,7 @@ export function createInjectionSource(features, {
|
|
|
2810
2832
|
typeof controller?.openTab !== "function"
|
|
2811
2833
|
|| typeof controller?.activateTab !== "function"
|
|
2812
2834
|
|| typeof controller?.closeTab !== "function"
|
|
2835
|
+
|| !controller?.tabById$
|
|
2813
2836
|
|| typeof jsx?.jsx !== "function"
|
|
2814
2837
|
|| typeof React?.useLayoutEffect !== "function"
|
|
2815
2838
|
|| typeof React?.useRef !== "function"
|
|
@@ -2923,17 +2946,31 @@ export function createInjectionSource(features, {
|
|
|
2923
2946
|
return reloaded;
|
|
2924
2947
|
}
|
|
2925
2948
|
|
|
2949
|
+
async function waitForNativeDetailTabMount(scope, controller, tabId, detailKey) {
|
|
2950
|
+
const deadline = performance.now() + 2_000;
|
|
2951
|
+
do {
|
|
2952
|
+
let registered = false;
|
|
2953
|
+
try {
|
|
2954
|
+
registered = scope.get(controller.tabById$, tabId) != null;
|
|
2955
|
+
} catch {}
|
|
2956
|
+
if (registered && nativeDetailSurfaceKeys.has(detailKey)) return true;
|
|
2957
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2958
|
+
} while (performance.now() < deadline);
|
|
2959
|
+
return false;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2926
2962
|
async function openNativeDetailTab(feature, detail) {
|
|
2927
2963
|
const capability = await loadNativeTabCapability();
|
|
2928
2964
|
if (!capability) return null;
|
|
2929
|
-
const scope = await
|
|
2965
|
+
const scope = await waitForCurrentRightPanelScope();
|
|
2930
2966
|
if (!scope) {
|
|
2931
|
-
nativeTabCapabilityError = new Error("The current Codex
|
|
2967
|
+
nativeTabCapabilityError = new Error("The current Codex right-panel scope was not found");
|
|
2932
2968
|
return null;
|
|
2933
2969
|
}
|
|
2934
2970
|
|
|
2935
2971
|
const { controller, jsx } = capability;
|
|
2936
2972
|
const tabId = `${feature.id}-${detail.id}`;
|
|
2973
|
+
const detailKey = `${feature.id}:${detail.id}`;
|
|
2937
2974
|
let session = Array.from(nativeOpenTabSessions).find((candidate) => (
|
|
2938
2975
|
candidate.scope === scope && candidate.tabId === tabId
|
|
2939
2976
|
));
|
|
@@ -2966,6 +3003,17 @@ export function createInjectionSource(features, {
|
|
|
2966
3003
|
return null;
|
|
2967
3004
|
}
|
|
2968
3005
|
|
|
3006
|
+
if (!await waitForNativeDetailTabMount(scope, controller, tabId, detailKey)) {
|
|
3007
|
+
if (isNewSession) nativeOpenTabSessions.delete(session);
|
|
3008
|
+
if (!focusedExisting) {
|
|
3009
|
+
try {
|
|
3010
|
+
controller.closeTab(scope, tabId);
|
|
3011
|
+
} catch {}
|
|
3012
|
+
}
|
|
3013
|
+
nativeTabCapabilityError = new Error("Codex native right-panel Tab did not mount");
|
|
3014
|
+
return null;
|
|
3015
|
+
}
|
|
3016
|
+
|
|
2969
3017
|
nativeTabCapabilityError = null;
|
|
2970
3018
|
if (focusedExisting) reloadNativeDetailIfUnready(feature, detail);
|
|
2971
3019
|
return { tabId, focusedExisting, presentation: "native" };
|
|
@@ -3423,6 +3471,11 @@ export function createInjectionSource(features, {
|
|
|
3423
3471
|
resolveHostAction,
|
|
3424
3472
|
dispose: () => {
|
|
3425
3473
|
lifetime.abort();
|
|
3474
|
+
for (const cleanup of pageScriptCleanups.splice(0)) {
|
|
3475
|
+
try { cleanup(); } catch (error) {
|
|
3476
|
+
console.warn("[codex-personal] page-script cleanup failed", error);
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3426
3479
|
if (ensureFrame != null) cancelAnimationFrame(ensureFrame);
|
|
3427
3480
|
if (ensureTimer != null) clearTimeout(ensureTimer);
|
|
3428
3481
|
for (const observer of domObservers) observer.disconnect();
|
|
@@ -3459,6 +3512,7 @@ export function createInjectionSource(features, {
|
|
|
3459
3512
|
detailTabs: feature.detailTabs ?? [],
|
|
3460
3513
|
hostActions: feature.hostActions ?? [],
|
|
3461
3514
|
modelSelector: feature.modelSelector ?? null,
|
|
3515
|
+
pageScript: feature.pageScript ?? null,
|
|
3462
3516
|
}));
|
|
3463
3517
|
return `(${install.toString()})(${JSON.stringify({
|
|
3464
3518
|
version: RUNTIME_VERSION,
|
|
@@ -3466,6 +3520,7 @@ export function createInjectionSource(features, {
|
|
|
3466
3520
|
bindingName,
|
|
3467
3521
|
bindingToken,
|
|
3468
3522
|
summaryLayout: RESPONSIVE_SUMMARY_LAYOUT,
|
|
3523
|
+
rightPanelRouteKinds: RIGHT_PANEL_ROUTE_KINDS,
|
|
3469
3524
|
features: runtimeFeatures,
|
|
3470
3525
|
})})`;
|
|
3471
3526
|
}
|
|
@@ -13,6 +13,8 @@ const HOST_ACTIONS = new Set([
|
|
|
13
13
|
"resolve-file-paths",
|
|
14
14
|
]);
|
|
15
15
|
|
|
16
|
+
const PAGE_SCRIPT_ENTRY_KINDS = new Set(["page-script"]);
|
|
17
|
+
|
|
16
18
|
export const FEATURE_PROTOCOL_VERSION = 1;
|
|
17
19
|
export const FEATURE_HEALTH_TIMEOUT_MS = 750;
|
|
18
20
|
export const FEATURE_STARTUP_TIMEOUT_MS = 1_000;
|
|
@@ -127,6 +129,23 @@ async function normalizeModelSelector(modelSelector, projectDir) {
|
|
|
127
129
|
};
|
|
128
130
|
}
|
|
129
131
|
|
|
132
|
+
async function normalizePageScript(manifest, projectDir) {
|
|
133
|
+
const resource = manifest.entry?.script ?? manifest.pageScript?.resource ?? manifest.script;
|
|
134
|
+
if (!resource) {
|
|
135
|
+
throw new Error("page-script entries require entry.script or script.resource");
|
|
136
|
+
}
|
|
137
|
+
const scriptPath = path.resolve(projectDir, resource);
|
|
138
|
+
const relative = path.relative(projectDir, scriptPath);
|
|
139
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
140
|
+
throw new Error("page-script resource must stay inside the feature project");
|
|
141
|
+
}
|
|
142
|
+
const source = await readFile(scriptPath, "utf8");
|
|
143
|
+
if (!source.trim()) {
|
|
144
|
+
throw new Error("page-script resource must be a non-empty script file");
|
|
145
|
+
}
|
|
146
|
+
return { resource, source };
|
|
147
|
+
}
|
|
148
|
+
|
|
130
149
|
async function normalizeFeature(manifest, projectDir) {
|
|
131
150
|
if (manifest.schemaVersion !== 1) throw new Error("schemaVersion must be 1");
|
|
132
151
|
if (!manifest.id || !manifest.label) {
|
|
@@ -135,11 +154,12 @@ async function normalizeFeature(manifest, projectDir) {
|
|
|
135
154
|
|
|
136
155
|
const label = normalizeLocalizedText(manifest.label, "label");
|
|
137
156
|
const entryKind = manifest.entry?.kind ?? "sidebar";
|
|
138
|
-
if (!new Set(["sidebar", "conversation-toolbar", "composer-model-selector"]).has(entryKind)) {
|
|
139
|
-
throw new Error("entry.kind must be sidebar, conversation-toolbar,
|
|
157
|
+
if (!new Set(["sidebar", "conversation-toolbar", "composer-model-selector", ...PAGE_SCRIPT_ENTRY_KINDS]).has(entryKind)) {
|
|
158
|
+
throw new Error("entry.kind must be sidebar, conversation-toolbar, composer-model-selector, or page-script");
|
|
140
159
|
}
|
|
141
160
|
const isModelSelector = entryKind === "composer-model-selector";
|
|
142
|
-
|
|
161
|
+
const isPageScript = PAGE_SCRIPT_ENTRY_KINDS.has(entryKind);
|
|
162
|
+
if (!isModelSelector && !isPageScript && !manifest.surfaceUrl) {
|
|
143
163
|
throw new Error("surfaceUrl is required for rendered features");
|
|
144
164
|
}
|
|
145
165
|
if (entryKind === "conversation-toolbar" && !manifest.entry?.ariaLabel) {
|
|
@@ -222,6 +242,9 @@ async function normalizeFeature(manifest, projectDir) {
|
|
|
222
242
|
modelSelector: isModelSelector
|
|
223
243
|
? await normalizeModelSelector(manifest.modelSelector, projectDir)
|
|
224
244
|
: null,
|
|
245
|
+
pageScript: isPageScript
|
|
246
|
+
? await normalizePageScript(manifest, projectDir)
|
|
247
|
+
: null,
|
|
225
248
|
placement: {
|
|
226
249
|
after: manifest.placement?.after ?? "sites",
|
|
227
250
|
order: Number(manifest.placement?.order ?? 100),
|