icuewidget-cli 0.4.44

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.
@@ -0,0 +1,48 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export async function pathExists(filePath) {
5
+ try {
6
+ await fs.access(filePath);
7
+ return true;
8
+ } catch {
9
+ return false;
10
+ }
11
+ }
12
+
13
+ export function ensureInside(root, candidate) {
14
+ const relative = path.relative(root, candidate);
15
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
16
+ }
17
+
18
+ export function toZipPath(filePath) {
19
+ return filePath.split(path.sep).join("/");
20
+ }
21
+
22
+ export async function walkFiles(root) {
23
+ const files = [];
24
+ async function walk(dir) {
25
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
26
+ const absolute = path.join(dir, entry.name);
27
+ if (entry.isDirectory()) await walk(absolute);
28
+ else if (entry.isFile()) files.push(absolute);
29
+ }
30
+ }
31
+ await walk(root);
32
+ return files;
33
+ }
34
+
35
+ export async function readJson(filePath) {
36
+ try {
37
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
38
+ } catch (error) {
39
+ if (error instanceof SyntaxError) {
40
+ throw new Error(`${path.basename(filePath)} is not valid JSON: ${error.message}`);
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ export async function writeJson(filePath, value) {
47
+ await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
48
+ }
@@ -0,0 +1,52 @@
1
+ import path from "node:path";
2
+ import { APP_VERSION_RE, DEFAULT_APP_VERSION, SEMVER_RE } from "./constants.js";
3
+ import { readJson, writeJson } from "./fs-utils.js";
4
+ import { promptValidated } from "./prompts.js";
5
+
6
+ export function bumpPatchVersion(value) {
7
+ const [major, minor, patch] = value.split(".");
8
+ return `${major}.${minor}.${Number(patch) + 1}`;
9
+ }
10
+
11
+ export async function updateManifestToLatest(widgetDir, options = {}) {
12
+ const manifestPath = path.join(widgetDir, "manifest.json");
13
+ const manifest = await readJson(manifestPath);
14
+ const changes = [];
15
+ const requirementChanges = [];
16
+
17
+ if (typeof manifest.min_app_version !== "string" || !APP_VERSION_RE.test(manifest.min_app_version.trim())) {
18
+ let value = options.minAppVersion;
19
+ if (value !== undefined) {
20
+ value = value.trim();
21
+ if (!APP_VERSION_RE.test(value)) throw new Error("--min-app-version must use iCUE version format like 5.45 or 5.45.104.");
22
+ } else {
23
+ value = await promptValidated(
24
+ "Minimum iCUE version required",
25
+ DEFAULT_APP_VERSION,
26
+ (answer) => APP_VERSION_RE.test(answer),
27
+ "Use iCUE version format like 5.45 or 5.45.104.",
28
+ );
29
+ }
30
+ manifest.min_app_version = value;
31
+ requirementChanges.push("manifest.min_app_version");
32
+ }
33
+
34
+ if (options.version !== undefined) {
35
+ const version = options.version.trim();
36
+ if (!SEMVER_RE.test(version)) throw new Error("--version must use semantic version format like 1.0.1.");
37
+ if (manifest.version !== version) {
38
+ manifest.version = version;
39
+ changes.push("manifest.version");
40
+ }
41
+ } else if (requirementChanges.length) {
42
+ if (typeof manifest.version !== "string" || !SEMVER_RE.test(manifest.version.trim())) {
43
+ throw new Error("manifest.version must use semantic version format like 1.0.0, or pass --version to set it.");
44
+ }
45
+ manifest.version = bumpPatchVersion(manifest.version.trim());
46
+ changes.push("manifest.version");
47
+ }
48
+
49
+ changes.push(...requirementChanges);
50
+ if (changes.length) await writeJson(manifestPath, manifest);
51
+ return changes;
52
+ }
@@ -0,0 +1,28 @@
1
+ import readline from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+
4
+ let rl;
5
+
6
+ function reader() {
7
+ rl ??= readline.createInterface({ input, output });
8
+ return rl;
9
+ }
10
+
11
+ export async function prompt(label, defaultValue = "") {
12
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
13
+ const answer = (await reader().question(`${label}${suffix}: `)).trim();
14
+ return answer || defaultValue;
15
+ }
16
+
17
+ export async function promptValidated(label, defaultValue, validator, errorMessage) {
18
+ while (true) {
19
+ const answer = await prompt(label, defaultValue);
20
+ if (validator(answer)) return answer;
21
+ console.error(errorMessage);
22
+ }
23
+ }
24
+
25
+ export function closePrompts() {
26
+ rl?.close();
27
+ rl = undefined;
28
+ }
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import {
4
+ DEFAULT_APP_VERSION,
5
+ DEFAULT_DEVICE_TYPE,
6
+ DEFAULT_FRAMEWORK_VERSION,
7
+ DEFAULT_PLATFORM,
8
+ } from "./constants.js";
9
+ import {
10
+ cssTemplate,
11
+ iconTemplate,
12
+ indexTemplate,
13
+ jsTemplate,
14
+ translationTemplate,
15
+ } from "./templates.js";
16
+ import { writeJson } from "./fs-utils.js";
17
+
18
+ export async function scaffoldWidget(targetDir, answers) {
19
+ await fs.mkdir(targetDir, { recursive: true });
20
+ await Promise.all([
21
+ fs.mkdir(path.join(targetDir, "resources"), { recursive: true }),
22
+ fs.mkdir(path.join(targetDir, "scripts"), { recursive: true }),
23
+ fs.mkdir(path.join(targetDir, "styles"), { recursive: true }),
24
+ ]);
25
+
26
+ const manifest = {
27
+ author: answers.author,
28
+ id: answers.widgetId,
29
+ name: answers.widgetName,
30
+ description: answers.description,
31
+ version: answers.version,
32
+ preview_icon: answers.previewIcon,
33
+ min_app_version: answers.minAppVersion ?? DEFAULT_APP_VERSION,
34
+ min_framework_version: DEFAULT_FRAMEWORK_VERSION,
35
+ os: [{ platform: DEFAULT_PLATFORM }],
36
+ supported_devices: [{ type: DEFAULT_DEVICE_TYPE }],
37
+ };
38
+
39
+ await writeJson(path.join(targetDir, "manifest.json"), manifest);
40
+ await writeJson(path.join(targetDir, "translation.json"), translationTemplate(answers.widgetName));
41
+ await fs.writeFile(path.join(targetDir, "index.html"), indexTemplate(answers.widgetName), "utf8");
42
+ await fs.writeFile(path.join(targetDir, "styles", "main.css"), cssTemplate, "utf8");
43
+ await fs.writeFile(path.join(targetDir, "scripts", "main.js"), jsTemplate, "utf8");
44
+ await fs.writeFile(path.join(targetDir, "resources", "icon.svg"), iconTemplate(answers.widgetName), "utf8");
45
+
46
+ return manifest;
47
+ }
@@ -0,0 +1,151 @@
1
+ export function translationTemplate(widgetName) {
2
+ return {
3
+ widget_name: widgetName,
4
+ Layout: "Layout",
5
+ "Widget Personalization": "Widget Personalization",
6
+ Headline: "Headline",
7
+ Subtitle: "Subtitle",
8
+ "Show Subtitle": "Show Subtitle",
9
+ "Text Color": "Text Color",
10
+ "Accent Color": "Accent Color",
11
+ Background: "Background",
12
+ Transparency: "Transparency",
13
+ "Hello World": "Hello World",
14
+ };
15
+ }
16
+
17
+ export function indexTemplate(widgetName) {
18
+ return `<!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="utf-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <title>${escapeHtml(widgetName)}</title>
24
+ <link rel="icon" href="resources/icon.svg" type="image/svg+xml">
25
+ <link rel="stylesheet" href="styles/main.css">
26
+ <meta name="x-icue-property" content='{"name":"headline","label":"Headline","type":"text","default":"Hello World"}'>
27
+ <meta name="x-icue-property" content='{"name":"showSubtitle","label":"Show Subtitle","type":"boolean","default":true}'>
28
+ <meta name="x-icue-property" content='{"name":"subtitle","label":"Subtitle","type":"text","default":"Built with iCUE Widget CLI"}'>
29
+ <meta name="x-icue-property" content='{"name":"textColor","label":"Text Color","type":"color","default":"#ffffff"}'>
30
+ <meta name="x-icue-property" content='{"name":"accentColor","label":"Accent Color","type":"color","default":"#59c2ff"}'>
31
+ <meta name="x-icue-property" content='{"name":"backgroundColor","label":"Background","type":"color","default":"#111827"}'>
32
+ <meta name="x-icue-property" content='{"name":"transparency","label":"Transparency","type":"number","min":0,"max":100,"default":20}'>
33
+ </head>
34
+ <body>
35
+ <main id="content">
36
+ <div id="accent"></div>
37
+ <h1 id="headline">Hello World</h1>
38
+ <p id="subtitle">Built with iCUE Widget CLI</p>
39
+ </main>
40
+ <script src="scripts/main.js"></script>
41
+ </body>
42
+ </html>
43
+ `;
44
+ }
45
+
46
+ export const cssTemplate = `:root {
47
+ --text-color: #ffffff;
48
+ --accent-color: #59c2ff;
49
+ --background-color: #111827;
50
+ --transparency: 20%;
51
+ }
52
+
53
+ html,
54
+ body {
55
+ height: 100%;
56
+ margin: 0;
57
+ }
58
+
59
+ body {
60
+ display: grid;
61
+ place-items: center;
62
+ background: transparent;
63
+ font-family: Inter, Segoe UI, system-ui, sans-serif;
64
+ }
65
+
66
+ #content {
67
+ min-width: 60%;
68
+ padding: 24px 28px;
69
+ border-radius: 24px;
70
+ color: var(--text-color);
71
+ background: color-mix(in srgb, var(--background-color) calc(100% - var(--transparency)), transparent);
72
+ text-align: center;
73
+ }
74
+
75
+ #headline {
76
+ margin: 0;
77
+ font-size: 32px;
78
+ font-weight: 700;
79
+ }
80
+
81
+ #accent {
82
+ width: 56px;
83
+ height: 4px;
84
+ margin: 0 auto 16px;
85
+ border-radius: 999px;
86
+ background: var(--accent-color);
87
+ }
88
+
89
+ #subtitle {
90
+ margin: 10px 0 0;
91
+ font-size: 18px;
92
+ opacity: 0.8;
93
+ }
94
+ `;
95
+
96
+ export const jsTemplate = `const state = {
97
+ headline: "Hello World",
98
+ subtitle: "Built with iCUE Widget CLI",
99
+ showSubtitle: true,
100
+ textColor: "#ffffff",
101
+ accentColor: "#59c2ff",
102
+ backgroundColor: "#111827",
103
+ transparency: 20,
104
+ };
105
+
106
+ function applySettings(settings = {}) {
107
+ Object.assign(state, settings);
108
+ document.documentElement.style.setProperty("--text-color", state.textColor);
109
+ document.documentElement.style.setProperty("--accent-color", state.accentColor);
110
+ document.documentElement.style.setProperty("--background-color", state.backgroundColor);
111
+ document.documentElement.style.setProperty("--transparency", state.transparency + "%");
112
+ document.getElementById("headline").textContent = state.headline;
113
+ const subtitle = document.getElementById("subtitle");
114
+ subtitle.textContent = state.subtitle;
115
+ subtitle.hidden = !state.showSubtitle;
116
+ }
117
+
118
+ window.icueEvents = {
119
+ onICUEInitialized(payload) {
120
+ applySettings(payload?.settings);
121
+ },
122
+ onDataUpdated(payload) {
123
+ applySettings(payload?.settings);
124
+ },
125
+ };
126
+
127
+ applySettings();
128
+ `;
129
+
130
+ export function iconTemplate(widgetName) {
131
+ const title = escapeHtml(widgetName);
132
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="${title}">
133
+ <defs>
134
+ <linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
135
+ <stop stop-color="#59c2ff"/>
136
+ <stop offset="1" stop-color="#7c3aed"/>
137
+ </linearGradient>
138
+ </defs>
139
+ <rect width="128" height="128" rx="28" fill="#111827"/>
140
+ <path d="M29 84h70M34 64h60M43 44h42" stroke="url(#g)" stroke-width="10" stroke-linecap="round"/>
141
+ </svg>
142
+ `;
143
+ }
144
+
145
+ function escapeHtml(value) {
146
+ return String(value)
147
+ .replaceAll("&", "&amp;")
148
+ .replaceAll("<", "&lt;")
149
+ .replaceAll(">", "&gt;")
150
+ .replaceAll('"', "&quot;");
151
+ }
@@ -0,0 +1,70 @@
1
+ const enabled = process.env.NO_COLOR === undefined && process.stdout.isTTY;
2
+
3
+ const code = (open, close) => (value) => enabled ? `\u001b[${open}m${value}\u001b[${close}m` : String(value);
4
+
5
+ export const color = {
6
+ bold: code(1, 22),
7
+ dim: code(2, 22),
8
+ cyan: code(36, 39),
9
+ green: code(32, 39),
10
+ yellow: code(33, 39),
11
+ red: code(31, 39),
12
+ magenta: code(35, 39),
13
+ };
14
+
15
+ export function header(appName, version) {
16
+ console.log(`${color.bold(appName)} ${color.cyan(version)}`);
17
+ console.log(color.dim("iCUE widget packaging and scaffolding CLI"));
18
+ }
19
+
20
+ export function panel(title, rows, borderColor = color.cyan) {
21
+ const rowText = rows.map((row) => Array.isArray(row) ? row.join(" ") : String(row));
22
+ const maxWidth = Math.max(40, Math.min(76, (process.stdout.columns ?? 80) - 4));
23
+ const normalized = rowText.flatMap((row) => wrapLine(row, maxWidth));
24
+ const contentWidth = Math.min(maxWidth, Math.max(
25
+ title.length + 2,
26
+ ...normalized.map((row) => visibleLength(row)),
27
+ ));
28
+ const top = `╭─ ${title} ${"─".repeat(Math.max(0, contentWidth - title.length - 1))}╮`;
29
+ const bottom = `╰${"─".repeat(contentWidth + 2)}╯`;
30
+ console.log(borderColor(top));
31
+ for (const content of normalized) {
32
+ console.log(`${borderColor("│")} ${content}${" ".repeat(Math.max(0, contentWidth - visibleLength(content)))} ${borderColor("│")}`);
33
+ }
34
+ console.log(borderColor(bottom));
35
+ }
36
+
37
+ export function table(title, rows) {
38
+ const keyWidth = Math.max(...rows.map(([key]) => key.length));
39
+ panel(title, rows.map(([key, value]) => [color.dim(key.padEnd(keyWidth)), value]), color.cyan);
40
+ }
41
+
42
+ export function success(message) {
43
+ panel("Success", [color.green(`✓ ${message}`)], color.green);
44
+ }
45
+
46
+ export function info(message) {
47
+ panel("Info", [color.cyan(message)], color.cyan);
48
+ }
49
+
50
+ function visibleLength(value) {
51
+ return String(value).replace(/\u001b\[[0-9;]*m/g, "").length;
52
+ }
53
+
54
+ function wrapLine(value, width) {
55
+ if (visibleLength(value) <= width) return [value];
56
+ const words = String(value).split(" ");
57
+ const lines = [];
58
+ let current = "";
59
+ for (const word of words) {
60
+ const next = current ? `${current} ${word}` : word;
61
+ if (visibleLength(next) <= width) {
62
+ current = next;
63
+ } else {
64
+ if (current) lines.push(current);
65
+ current = ` ${word}`;
66
+ }
67
+ }
68
+ if (current) lines.push(current);
69
+ return lines;
70
+ }
@@ -0,0 +1,97 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { APP_NAME, CLI_VERSION, NPM_PACKAGE_NAME } from "./constants.js";
6
+
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE_NAME).replace("%40", "@")}/latest`;
9
+
10
+ export async function notifyAboutUpdate({
11
+ argv = process.argv,
12
+ env = process.env,
13
+ now = Date.now(),
14
+ stderr = process.stderr,
15
+ } = {}) {
16
+ if (env.ICUEWIDGET_NO_UPDATE_CHECK === "1" || env.ICUEWIDGET_UPDATE_CHECK_WORKER === "1" || argv.includes("--update-check-worker")) return;
17
+
18
+ let cache;
19
+ try {
20
+ cache = await readCache(env);
21
+ } catch {
22
+ cache = undefined;
23
+ }
24
+
25
+ if (cache?.latestVersion && isNewerVersion(cache.latestVersion, CLI_VERSION)) {
26
+ printUpdateNotice(stderr, cache.latestVersion);
27
+ }
28
+
29
+ const checkedAt = Number(cache?.checkedAt ?? 0);
30
+ if (!Number.isFinite(checkedAt) || now - checkedAt >= DAY_MS) {
31
+ runUpdateCheckWorker({ env }).catch(() => {});
32
+ }
33
+ }
34
+
35
+ export async function runUpdateCheckWorker({ env = process.env, fetchImpl = globalThis.fetch, now = Date.now() } = {}) {
36
+ if (typeof fetchImpl !== "function") return;
37
+ try {
38
+ const latestVersion = await fetchLatestVersion(fetchImpl);
39
+ if (!latestVersion) return;
40
+ await writeCache(env, { latestVersion, checkedAt: now });
41
+ } catch {
42
+ // Update checks are best-effort and must never make CLI commands noisy/fail.
43
+ }
44
+ }
45
+
46
+ export function isNewerVersion(candidate, current) {
47
+ const a = parseSemver(candidate);
48
+ const b = parseSemver(current);
49
+ if (!a || !b) return false;
50
+ for (let i = 0; i < 3; i += 1) {
51
+ if (a[i] > b[i]) return true;
52
+ if (a[i] < b[i]) return false;
53
+ }
54
+ return false;
55
+ }
56
+
57
+ async function fetchLatestVersion(fetchImpl) {
58
+ const controller = new AbortController();
59
+ const timeout = setTimeout(() => controller.abort(), 3000);
60
+ try {
61
+ const response = await fetchImpl(REGISTRY_URL, {
62
+ headers: { "Accept": "application/json", "User-Agent": `${APP_NAME}/${CLI_VERSION}` },
63
+ signal: controller.signal,
64
+ });
65
+ if (!response.ok) return undefined;
66
+ const body = await response.json();
67
+ return typeof body.version === "string" ? body.version : undefined;
68
+ } finally {
69
+ clearTimeout(timeout);
70
+ }
71
+ }
72
+
73
+ async function readCache(env) {
74
+ return JSON.parse(await fs.readFile(cachePath(env), "utf8"));
75
+ }
76
+
77
+ async function writeCache(env, data) {
78
+ const filePath = cachePath(env);
79
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
80
+ await fs.writeFile(filePath, `${JSON.stringify(data)}\n`, "utf8");
81
+ }
82
+
83
+ function cachePath(env) {
84
+ const base = env.ICUEWIDGET_UPDATE_CACHE_DIR || env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
85
+ return path.join(base, "icuewidget-cli", "update-check.json");
86
+ }
87
+
88
+ function parseSemver(value) {
89
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
90
+ return match ? match.slice(1, 4).map(Number) : undefined;
91
+ }
92
+
93
+ function printUpdateNotice(stderr, latestVersion) {
94
+ stderr.write(`Update available: ${APP_NAME} ${CLI_VERSION} → ${latestVersion}\n`);
95
+ stderr.write("npm:\n");
96
+ stderr.write(` npm install -g ${NPM_PACKAGE_NAME}@latest\n`);
97
+ }
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import vm from "node:vm";
4
+ import {
5
+ ALLOWED_DEVICE_TYPES,
6
+ ALLOWED_PLATFORMS,
7
+ APP_VERSION_RE,
8
+ IDENTIFIER_RE,
9
+ SEMVER_RE,
10
+ } from "./constants.js";
11
+ import { ensureInside, pathExists, readJson } from "./fs-utils.js";
12
+
13
+ export class ValidationError extends Error {}
14
+
15
+ export async function validateWidget(widgetDir) {
16
+ const root = path.resolve(widgetDir);
17
+ const warnings = [];
18
+
19
+ for (const file of ["manifest.json", "index.html"]) {
20
+ if (!(await pathExists(path.join(root, file)))) {
21
+ throw new ValidationError(`Required file missing: ${file}`);
22
+ }
23
+ }
24
+
25
+ const manifest = await validateManifest(root);
26
+ if (await pathExists(path.join(root, "translation.json"))) {
27
+ await readJson(path.join(root, "translation.json"));
28
+ } else {
29
+ warnings.push("translation.json is missing; localized labels will not be available.");
30
+ }
31
+
32
+ await validateIndex(root, warnings);
33
+ return { widgetDir: root, manifest, warnings };
34
+ }
35
+
36
+ async function validateManifest(root) {
37
+ const manifest = await readJson(path.join(root, "manifest.json"));
38
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
39
+ throw new ValidationError("manifest.json must contain a JSON object");
40
+ }
41
+
42
+ for (const key of [
43
+ "author",
44
+ "id",
45
+ "name",
46
+ "description",
47
+ "version",
48
+ "preview_icon",
49
+ "min_app_version",
50
+ "min_framework_version",
51
+ ]) {
52
+ requireString(manifest, key, `manifest.${key}`);
53
+ }
54
+
55
+ if (!IDENTIFIER_RE.test(manifest.id)) throw new ValidationError("manifest.id may only contain letters, numbers, dots, and hyphens");
56
+ if (!SEMVER_RE.test(manifest.version)) throw new ValidationError("manifest.version must use semantic version format (for example 1.0.0)");
57
+ if (!APP_VERSION_RE.test(manifest.min_app_version)) throw new ValidationError("manifest.min_app_version must use iCUE version format (for example 5.45)");
58
+ if (!SEMVER_RE.test(manifest.min_framework_version)) throw new ValidationError("manifest.min_framework_version must use semantic version format (for example 1.0.0)");
59
+
60
+ await validateRelativeFile(root, manifest.preview_icon, "manifest.preview_icon");
61
+
62
+ if (!Array.isArray(manifest.os) || manifest.os.length === 0) throw new ValidationError("manifest.os must be a non-empty array");
63
+ for (const [index, os] of manifest.os.entries()) {
64
+ if (!os || typeof os !== "object") throw new ValidationError(`manifest.os[${index}] must be an object`);
65
+ if (!ALLOWED_PLATFORMS.has(os.platform)) throw new ValidationError(`manifest.os[${index}].platform must be one of: ${[...ALLOWED_PLATFORMS].join(", ")}`);
66
+ }
67
+
68
+ if (!Array.isArray(manifest.supported_devices) || manifest.supported_devices.length === 0) {
69
+ throw new ValidationError("manifest.supported_devices must be a non-empty array");
70
+ }
71
+ for (const [index, device] of manifest.supported_devices.entries()) {
72
+ if (!device || typeof device !== "object") throw new ValidationError(`manifest.supported_devices[${index}] must be an object`);
73
+ if (!ALLOWED_DEVICE_TYPES.has(device.type)) throw new ValidationError(`manifest.supported_devices[${index}].type must be one of: ${[...ALLOWED_DEVICE_TYPES].join(", ")}`);
74
+ if (device.features !== undefined) validateStringArray(device.features, `manifest.supported_devices[${index}].features`);
75
+ }
76
+
77
+ if (manifest.interactive !== undefined && typeof manifest.interactive !== "boolean") {
78
+ throw new ValidationError("manifest.interactive must be a boolean when provided");
79
+ }
80
+ if (manifest.required_plugins !== undefined) validateStringArray(manifest.required_plugins, "manifest.required_plugins");
81
+ if (manifest.modules !== undefined) {
82
+ validateStringArray(manifest.modules, "manifest.modules");
83
+ for (const modulePath of manifest.modules) await validateRelativeFile(root, modulePath, "manifest.modules");
84
+ }
85
+
86
+ return manifest;
87
+ }
88
+
89
+ async function validateIndex(root, warnings) {
90
+ const html = await fs.readFile(path.join(root, "index.html"), "utf8");
91
+ if (!/<title>[^<]+<\/title>/i.test(html)) warnings.push("index.html does not define a <title>.");
92
+ if (!/rel=["'][^"']*icon/i.test(html)) warnings.push("index.html does not reference a favicon/icon.");
93
+ if (!html.includes("icueEvents")) warnings.push("index.html or scripts do not reference icueEvents.");
94
+
95
+ const assetRefs = [...extractAttributes(html, "href"), ...extractAttributes(html, "src")];
96
+ for (const ref of assetRefs) {
97
+ if (isRemote(ref) || ref.startsWith("data:") || ref.startsWith("#")) continue;
98
+ await validateRelativeFile(root, stripQuery(ref), `index.html asset reference ${ref}`);
99
+ if (ref.endsWith(".css")) await validateCss(path.join(root, stripQuery(ref)));
100
+ if (ref.endsWith(".js")) await validateJs(path.join(root, stripQuery(ref)));
101
+ }
102
+ }
103
+
104
+ function* extractAttributes(html, attribute) {
105
+ const re = new RegExp(`${attribute}=["']([^"']+)["']`, "gi");
106
+ let match;
107
+ while ((match = re.exec(html))) yield match[1];
108
+ }
109
+
110
+ function stripQuery(value) {
111
+ return value.split(/[?#]/, 1)[0];
112
+ }
113
+
114
+ function isRemote(value) {
115
+ return /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//");
116
+ }
117
+
118
+ async function validateCss(filePath) {
119
+ const css = await fs.readFile(filePath, "utf8");
120
+ const stack = [];
121
+ const pairs = { "(": ")", "[": "]", "{": "}" };
122
+ for (const char of css) {
123
+ if (pairs[char]) stack.push(pairs[char]);
124
+ else if ([")", "]", "}"].includes(char) && stack.pop() !== char) {
125
+ throw new ValidationError(`${path.basename(filePath)} has unbalanced CSS delimiters`);
126
+ }
127
+ }
128
+ if (stack.length) throw new ValidationError(`${path.basename(filePath)} has unbalanced CSS delimiters`);
129
+ }
130
+
131
+ async function validateJs(filePath) {
132
+ const code = await fs.readFile(filePath, "utf8");
133
+ try {
134
+ new vm.Script(code, { filename: filePath });
135
+ } catch (error) {
136
+ throw new ValidationError(`${path.basename(filePath)} has invalid JavaScript: ${error.message}`);
137
+ }
138
+ }
139
+
140
+ async function validateRelativeFile(root, relativePath, label) {
141
+ const resolved = path.resolve(root, relativePath);
142
+ if (!ensureInside(root, resolved)) throw new ValidationError(`${label} must stay inside the widget directory`);
143
+ if (!(await pathExists(resolved))) throw new ValidationError(`${label} does not exist: ${relativePath}`);
144
+ }
145
+
146
+ function requireString(object, key, label) {
147
+ if (typeof object[key] !== "string" || object[key].trim() === "") {
148
+ throw new ValidationError(`${label} must be a non-empty string`);
149
+ }
150
+ }
151
+
152
+ function validateStringArray(value, label) {
153
+ if (!Array.isArray(value)) throw new ValidationError(`${label} must be an array`);
154
+ for (const [index, item] of value.entries()) {
155
+ if (typeof item !== "string" || item.trim() === "") throw new ValidationError(`${label}[${index}] must be a non-empty string`);
156
+ }
157
+ }