From 94d70d36f84edbbc0e7843325a4cc7a2ccc4c2c0 Mon Sep 17 00:00:00 2001 From: abujalance Date: Sun, 1 Mar 2026 16:56:56 +0100 Subject: [PATCH 1/4] feat: add icon support for items - Add uploadItemIcon, getItemIcon, removeItemIcon to EngineServicesClient - Add --icon flag to thatopen publish (persisted in .thatopen config) - Validates format (PNG/WebP/ICO) and size (512 KB max) before upload Co-Authored-By: Claude Opus 4.6 --- .changeset/icon-support.md | 9 +++++ src/cli/commands/publish.ts | 73 ++++++++++++++++++++++++++++++++++--- src/cli/lib/config.ts | 2 + src/core/client.ts | 40 ++++++++++++++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 .changeset/icon-support.md diff --git a/.changeset/icon-support.md b/.changeset/icon-support.md new file mode 100644 index 0000000..be0e4c5 --- /dev/null +++ b/.changeset/icon-support.md @@ -0,0 +1,9 @@ +--- +"thatopen-services": minor +--- + +Add icon support for items (apps, components, files). + +**Library:** New `uploadItemIcon`, `getItemIcon`, and `removeItemIcon` methods on `EngineServicesClient` for managing item icons via the `PUT/GET/DELETE /api/item/:id/icon` endpoints. Accepts PNG, WebP, and ICO images up to 512 KB. + +**CLI:** `thatopen publish --icon ` uploads an icon after publishing. The icon path is saved to `.thatopen` config so subsequent publishes reuse it automatically. diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 1db199d..3f7633c 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { existsSync, readFileSync } from 'node:fs'; -import { join, basename } from 'node:path'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { join, basename, extname } from 'node:path'; import { execSync } from 'node:child_process'; import { requireResolvedConfig, @@ -23,6 +23,7 @@ export const publishCommand = new Command('publish') 'Existing component ID to publish a new version for', ) .option('--skip-build', 'Skip the build step') + .option('--icon ', 'Path to an icon file (PNG, WebP, or ICO, max 512 KB)') .action( async (opts: { name?: string; @@ -30,6 +31,7 @@ export const publishCommand = new Command('publish') appId?: string; componentId?: string; skipBuild?: boolean; + icon?: string; }) => { const cwd = process.cwd(); const config = requireResolvedConfig(cwd); @@ -89,6 +91,9 @@ export const publishCommand = new Command('publish') const zipBuffer = readFileSync(zipPath); const zipBlob = new Blob([zipBuffer]); + // Resolve icon: CLI flag > local config + const iconPath = opts.icon || localConfig?.iconPath; + // Upload const client = new EngineServicesClient( config.accessToken, @@ -96,8 +101,10 @@ export const publishCommand = new Command('publish') ); try { + let itemId: string | undefined; + if (isComponent) { - await publishComponent( + itemId = await publishComponent( client, existingId, zipBlob, @@ -106,7 +113,7 @@ export const publishCommand = new Command('publish') cwd, ); } else { - await publishApp( + itemId = await publishApp( client, existingId, zipBlob, @@ -116,6 +123,11 @@ export const publishCommand = new Command('publish') ); } + // Upload icon if specified + if (iconPath && itemId) { + await uploadIcon(client, itemId, iconPath, opts.icon, cwd); + } + console.log('Published successfully!'); } catch (err) { const message = (err as Error).message || String(err); @@ -150,7 +162,7 @@ async function publishApp( name: string, versionTag: string, cwd: string, -) { +): Promise { if (appId) { // Auto-recover if the app was archived (deleted from UI) const existing = await client.getFile(appId); @@ -169,6 +181,7 @@ async function publishApp( {}, // extraProps required by backend for APP items ); console.log('Version created:', JSON.stringify(result, null, 2)); + return appId; } else { console.log(`Publishing new app "${name}" (${versionTag})...`); const result = await client.createApp({ @@ -184,6 +197,7 @@ async function publishApp( updateLocalConfig({ appId: String(newAppId) }, cwd); console.log(`App ID saved to .thatopen (${newAppId})`); } + return newAppId ? String(newAppId) : undefined; } } @@ -198,7 +212,7 @@ async function publishComponent( name: string, versionTag: string, cwd: string, -) { +): Promise { const componentProps = { type: 'CLOUD' as const, tier: 'FREE' as const, @@ -222,6 +236,7 @@ async function publishComponent( componentProps, }); console.log('Version created:', JSON.stringify(result, null, 2)); + return componentId; } else { console.log( `Publishing new cloud component "${name}" (${versionTag})...`, @@ -240,5 +255,51 @@ async function publishComponent( updateLocalConfig({ componentId: String(newId) }, cwd); console.log(`Component ID saved to .thatopen (${newId})`); } + return newId ? String(newId) : undefined; + } +} + +// --------------------------------------------------------------------------- +// Icon upload helper +// --------------------------------------------------------------------------- + +const ALLOWED_ICON_EXTENSIONS = ['.png', '.webp', '.ico']; +const MAX_ICON_SIZE = 512 * 1024; // 512 KB + +async function uploadIcon( + client: EngineServicesClient, + itemId: string, + iconPath: string, + cliIconFlag: string | undefined, + cwd: string, +) { + const resolvedPath = join(cwd, iconPath); + + if (!existsSync(resolvedPath)) { + console.error(`Icon file not found: ${resolvedPath}`); + return; + } + + const ext = extname(resolvedPath).toLowerCase(); + if (!ALLOWED_ICON_EXTENSIONS.includes(ext)) { + console.error(`Unsupported icon format "${ext}". Use PNG, WebP, or ICO.`); + return; + } + + const size = statSync(resolvedPath).size; + if (size > MAX_ICON_SIZE) { + console.error(`Icon too large (${Math.round(size / 1024)} KB). Maximum is 512 KB.`); + return; + } + + console.log('Uploading icon...'); + const iconBuffer = readFileSync(resolvedPath); + const iconBlob = new Blob([iconBuffer]); + await client.uploadItemIcon(itemId, iconBlob); + console.log('Icon uploaded.'); + + // Save icon path to local config if provided via CLI flag + if (cliIconFlag) { + updateLocalConfig({ iconPath: iconPath }, cwd); } } diff --git a/src/cli/lib/config.ts b/src/cli/lib/config.ts index b053863..dbd2ca6 100644 --- a/src/cli/lib/config.ts +++ b/src/cli/lib/config.ts @@ -49,6 +49,8 @@ export interface ThatOpenLocalConfig { componentId?: string; /** Project type marker set by `thatopen create`. */ itemType?: ProjectType; + /** Path to an icon file (PNG, WebP, or ICO) uploaded on publish. */ + iconPath?: string; } const LOCAL_CONFIG_FILE = '.thatopen'; diff --git a/src/core/client.ts b/src/core/client.ts index ba7b833..fdb2010 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -1079,6 +1079,46 @@ export class EngineServicesClient { ); } + // ─── Icons ─────────────────────────────────────────────────────── + + /** + * Uploads or replaces the icon for an item (app, component, or file). + * Accepts PNG, WebP, or ICO images up to 512 KB. + * @param itemId - The item's unique identifier. + * @param icon - The icon image file (File in browsers, Blob in Node.js). + * @returns The updated item with `iconFileId` and `iconMimeType` set. + */ + async uploadItemIcon(itemId: string, icon: File | Blob) { + const formData = new FormData(); + formData.append('icon', icon); + return await this.#requestApi( + 'PUT', + `${ITEM_PATH}/${itemId}/icon`, + { body: formData }, + ); + } + + /** + * Downloads the icon for an item as a binary stream. + * @param itemId - The item's unique identifier. + * @returns The raw Response (use `.blob()`, `.arrayBuffer()`, or pipe the body). + */ + async getItemIcon(itemId: string) { + return await this.#requestFile(`${ITEM_PATH}/${itemId}/icon`); + } + + /** + * Removes the icon from an item. + * @param itemId - The item's unique identifier. + * @returns The updated item with icon fields removed. + */ + async removeItemIcon(itemId: string) { + return await this.#requestApi( + 'DELETE', + `${ITEM_PATH}/${itemId}/icon`, + ); + } + // ─── General Item Operations ───────────────────────────────────── /** From 9977a29480cfab10051f2caf622a0c3e2cd29997 Mon Sep 17 00:00:00 2001 From: abujalance Date: Sun, 1 Mar 2026 17:10:37 +0100 Subject: [PATCH 2/4] refactor: remove all `as any` casts from client.ts - Augment Window interface for __THATOPEN_CONTEXT__ and ThatOpenCompany - Add ComponentsLike interface for OBC.Components duck-typing - Use typed inline casts for OBC/BUI globals in initApp - Zero `as any` remaining in src/ Co-Authored-By: Claude Opus 4.6 --- src/core/client.ts | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/core/client.ts b/src/core/client.ts index fdb2010..32ad7a5 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -20,6 +20,13 @@ import { CreateHiddenItemResult, HiddenFileEntity } from '../types/files'; import { Project, ProjectData } from '../types/projects'; import { ThatOpenContext } from '../types/context'; +declare global { + interface Window { + __THATOPEN_CONTEXT__?: ThatOpenContext; + ThatOpenCompany?: Record; + } +} + const FOLDER_PATH = 'item/folder'; const ITEM_PATH = 'item'; const PROCESS_PATH = 'processor'; @@ -29,6 +36,15 @@ const ITEM_TYPE_FILE = 'FILE'; const ITEM_TYPE_COMPONENT = 'TOOL'; const ITEM_TYPE_APP = 'APP'; +/** + * Minimal shape of an OBC.Components-like object. + * Avoids hard-coupling to `@thatopen/components` at the public API level. + */ +export interface ComponentsLike { + get(c: new (components: ComponentsLike) => unknown): unknown; + init(): void; +} + /** Properties for creating a new item (file, component, or app). */ export type CreateItemProps = { /** The file to upload (File in browsers, Blob in Node.js). */ @@ -180,7 +196,7 @@ export class EngineServicesClient { ): EngineServicesClient { const ctx: ThatOpenContext = (typeof window !== 'undefined' - ? (window as any).__THATOPEN_CONTEXT__ + ? window.__THATOPEN_CONTEXT__ : null) || { appId: '', projectId: '', accessToken: '', apiUrl: '' }; const client = new EngineServicesClient(ctx.accessToken, ctx.apiUrl, { ...props, @@ -710,14 +726,14 @@ export class EngineServicesClient { */ async initBuiltInComponent( component: { uuid: string }, - components: { get: (c: new (components: any) => any) => any }, + components: ComponentsLike, globals?: Record, ): Promise { const source = await this.getBuiltInComponent(component.uuid); const resolvedGlobals = globals ?? this.builtInGlobals ?? - (typeof window !== 'undefined' ? (window as any).ThatOpenCompany : {}) ?? + (typeof window !== 'undefined' ? window.ThatOpenCompany : {}) ?? {}; const keys = Object.keys(resolvedGlobals); @@ -746,7 +762,7 @@ export class EngineServicesClient { * ``` */ async initBuiltInComponents( - components: { get: (c: new (components: any) => any) => any }, + components: ComponentsLike, ...stubs: { uuid: string }[] ): Promise { await Promise.all( @@ -777,9 +793,9 @@ export class EngineServicesClient { async initApp( globals: Record, ...builtIns: { uuid: string }[] - ): Promise<{ components: any }> { - const OBC = globals.OBC as any; - const BUI = globals.BUI as any; + ): Promise<{ components: ComponentsLike }> { + const OBC = globals.OBC as { Components?: new () => ComponentsLike } | undefined; + const BUI = globals.BUI as { Manager?: { init(): void } } | undefined; if (!OBC?.Components) throw new Error('globals.OBC must include Components'); if (!BUI?.Manager) throw new Error('globals.BUI must include Manager'); From 906ecd6858a6faba46b3a6d9cd6b65ae92fd2f54 Mon Sep 17 00:00:00 2001 From: abujalance Date: Sun, 1 Mar 2026 17:13:03 +0100 Subject: [PATCH 3/4] refactor: enforce no-explicit-any via eslint and add lint to build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename eslint.config.js → .mjs, migrate .eslintignore to config ignores - Set @typescript-eslint/no-explicit-any to error - Add `eslint src/` as first step in build script - Fix ObjectId type (was `string | any`, now `string`) - Remove stale eslint-disable comment Co-Authored-By: Claude Opus 4.6 --- .eslintignore | 4 ---- eslint.config.mjs | 14 ++++++++++++++ package.json | 3 ++- src/core/client.ts | 1 - src/types/base.ts | 2 +- 5 files changed, 17 insertions(+), 7 deletions(-) delete mode 100644 .eslintignore create mode 100644 eslint.config.mjs diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 21efcd7..0000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -coverage -public -dist -pnpm-lock.yaml diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..5fbec52 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,14 @@ +import globals from "globals"; +import tseslint from "typescript-eslint"; + + +export default [ + { ignores: ["coverage/", "public/", "dist/"] }, + { languageOptions: { globals: globals.browser } }, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/no-explicit-any": "error", + }, + }, +]; \ No newline at end of file diff --git a/package.json b/package.json index 8a4dc0f..a8c2ba9 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ ], "scripts": { "dev": "vite", - "build": "tsc && vite build && vite build --config vite.config.cli.mts", + "lint": "eslint src/", + "build": "eslint src/ && tsc && vite build && vite build --config vite.config.cli.mts", "build:lib": "tsc && vite build", "build:cli": "vite build --config vite.config.cli.mts", "preview": "vite preview", diff --git a/src/core/client.ts b/src/core/client.ts index 32ad7a5..da28d26 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -739,7 +739,6 @@ export class EngineServicesClient { const keys = Object.keys(resolvedGlobals); const values = keys.map((k) => resolvedGlobals[k]); - // eslint-disable-next-line no-new-func const factory = new Function(...keys, `${source}\nreturn main;`); const main = factory(...values); diff --git a/src/types/base.ts b/src/types/base.ts index 96f0a34..361a1cb 100644 --- a/src/types/base.ts +++ b/src/types/base.ts @@ -7,4 +7,4 @@ export interface Base { archived?: boolean; } -export type ObjectId = string | any; +export type ObjectId = string; From 4809ba3530989888c7f126210efae756fc405aaa Mon Sep 17 00:00:00 2001 From: abujalance Date: Sun, 1 Mar 2026 17:13:42 +0100 Subject: [PATCH 4/4] chore: remove old eslint.config.js (renamed to .mjs) Co-Authored-By: Claude Opus 4.6 --- eslint.config.js | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 eslint.config.js diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index e7d54c3..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import globals from "globals"; -import tseslint from "typescript-eslint"; - - -export default [ - {languageOptions: { globals: globals.browser }}, - ...tseslint.configs.recommended, -]; \ No newline at end of file