From d0511008e964a056a1f0896d627792b7bdcfd45e Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:19:10 +0800 Subject: [PATCH 01/14] Test CI --- Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json b/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json index 6fc97ec..7b43ff2 100644 --- a/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json +++ b/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "name": "User Contributed", + "name": "User Contributed ", "color": null, "description": null, "thumbnailURL": null From c69c2b20f560891230c6a0a2ada4c8c4dc4922a6 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:22:10 +0800 Subject: [PATCH 02/14] Test catalog files fetch --- .github/workflows/ci-lint.yaml | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/ci-lint.yaml diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml new file mode 100644 index 0000000..8fff639 --- /dev/null +++ b/.github/workflows/ci-lint.yaml @@ -0,0 +1,46 @@ +name: CI Lint + +on: + push: + branches: [main] + pull_request: + +permissions: + checks: write + contents: read + id-token: write + pull-requests: write + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + concurrency: + group: lint-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + steps: + # Checkout the boxel monorepo to get tooling and init action + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + repository: cardstack/boxel + path: boxel + + # Checkout this catalog repo + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 + with: + path: boxel-catalog + + # Copy catalog files into the monorepo's catalog-realm location + - name: Copy catalog files into monorepo + run: | + cp -r boxel-catalog/* boxel/packages/catalog-realm/ + echo "Files copied to catalog-realm:" + ls -la boxel/packages/catalog-realm/ + + - uses: boxel/.github/actions/init + + - name: Lint Catalog + if: always() + run: pnpm run lint + working-directory: boxel/packages/catalog-realm + From e87135805b791101a03100cd018f27ea1fb8ef2c Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:22:59 +0800 Subject: [PATCH 03/14] Test catalog files fetc --- .github/workflows/ci-lint.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 8fff639..8f3aebb 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -37,7 +37,7 @@ jobs: echo "Files copied to catalog-realm:" ls -la boxel/packages/catalog-realm/ - - uses: boxel/.github/actions/init + - uses: ./boxel/.github/actions/init - name: Lint Catalog if: always() From a2e388c85a9b2c5fa5c84926ccbb2083baa5aef7 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:26:50 +0800 Subject: [PATCH 04/14] Test catalog files fetch --- .github/workflows/ci-lint.yaml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 8f3aebb..12e5923 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -19,28 +19,27 @@ jobs: group: lint-${{ github.head_ref || github.run_id }} cancel-in-progress: true steps: - # Checkout the boxel monorepo to get tooling and init action + # Checkout the boxel monorepo at root to get tooling and init action - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 with: repository: cardstack/boxel - path: boxel - # Checkout this catalog repo + # Checkout this catalog repo into a temp location - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 4.2.2 with: - path: boxel-catalog + path: boxel-catalog-src # Copy catalog files into the monorepo's catalog-realm location - name: Copy catalog files into monorepo run: | - cp -r boxel-catalog/* boxel/packages/catalog-realm/ + cp -r boxel-catalog-src/* packages/catalog-realm/ echo "Files copied to catalog-realm:" - ls -la boxel/packages/catalog-realm/ + ls -la packages/catalog-realm/ - - uses: ./boxel/.github/actions/init + - uses: ./.github/actions/init - name: Lint Catalog if: always() run: pnpm run lint - working-directory: boxel/packages/catalog-realm + working-directory: packages/catalog-realm From dbfca4716e9cf54bea3af6386b969e9fef473ab3 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:32:32 +0800 Subject: [PATCH 05/14] Test catalog files fetch --- .github/workflows/ci-lint.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 12e5923..1791ef9 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -32,6 +32,18 @@ jobs: # Copy catalog files into the monorepo's catalog-realm location - name: Copy catalog files into monorepo run: | + # Remove all files except config files we want to keep + cd packages/catalog-realm + find . -mindepth 1 \ + ! -name '.gitignore' \ + ! -name 'tsconfig.json' \ + ! -name 'package.json' \ + ! -name '.realm.json' \ + ! -name '.' \ + -exec rm -rf {} + 2>/dev/null || true + cd ../.. + + # Copy catalog files cp -r boxel-catalog-src/* packages/catalog-realm/ echo "Files copied to catalog-realm:" ls -la packages/catalog-realm/ From c7b8a3438168ea2f5acda346a79f5193b9e9018c Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:55:52 +0800 Subject: [PATCH 06/14] Test catalog files fetch --- .github/workflows/ci-lint.yaml | 4 ++++ tsconfig.json | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tsconfig.json diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 1791ef9..2c01d56 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -45,6 +45,10 @@ jobs: # Copy catalog files cp -r boxel-catalog-src/* packages/catalog-realm/ + + # Overwrite host tsconfig with catalog-only version + cp boxel-catalog-src/tsconfig.json packages/host/tsconfig.json + echo "Files copied to catalog-realm:" ls -la packages/catalog-realm/ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f521f14 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "es2020", + "allowJs": true, + "module": "es2022", + "moduleResolution": "nodenext", + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "noImplicitThis": true, + "alwaysStrict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noEmitOnError": false, + "noEmit": true, + "inlineSourceMap": true, + "inlineSources": true, + "baseUrl": ".", + "strict": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "paths": { + "https://cardstack.com/base/*": ["../base/*"], + "@cardstack/catalog/commands/*": ["../catalog-realm/commands/*"], + "@cardstack/catalog/*": ["../catalog-realm/catalog-app/*"], + "*": ["types/*"] + }, + "types": ["@cardstack/local-types"] + }, + "glint": { + "environment": ["ember-loose", "ember-template-imports"] + }, + "include": [ + "../base/**/*", + "../catalog-realm/**/*" + ], + "exclude": ["../base/**/__boxel/**"] +} From 35833b9e9cf64c99d74550cf3ee501b8323464b7 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 16:59:29 +0800 Subject: [PATCH 07/14] Test host lint --- .github/workflows/ci-lint.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 2c01d56..5d55508 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -58,4 +58,8 @@ jobs: if: always() run: pnpm run lint working-directory: packages/catalog-realm - + + - name: Lint Host + if: always() + run: pnpm run lint + working-directory: packages/host From df8926e30e99ce47cdbad2219d5cdb8b449d7746 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 17:02:31 +0800 Subject: [PATCH 08/14] Not to override tsconfig --- .github/workflows/ci-lint.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 5d55508..46c697d 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -46,9 +46,6 @@ jobs: # Copy catalog files cp -r boxel-catalog-src/* packages/catalog-realm/ - # Overwrite host tsconfig with catalog-only version - cp boxel-catalog-src/tsconfig.json packages/host/tsconfig.json - echo "Files copied to catalog-realm:" ls -la packages/catalog-realm/ From 9392358316e11d5ca06ce35b3f630b87414028e6 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 17:09:07 +0800 Subject: [PATCH 09/14] Build boxel icons and ui before lint --- .github/workflows/ci-lint.yaml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 46c697d..af980dd 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -51,6 +51,19 @@ jobs: - uses: ./.github/actions/init + + # Boxel UI and Boxel Icons need to be built first so that their + # linting outputs types are available for projects that depend on them + - name: Build Boxel Icons + # To faciliate linting of projects that depend on Boxel Icons + if: always() + run: pnpm run build + + - name: Build Boxel UI + # To faciliate linting of projects that depend on Boxel UI + if: always() + run: pnpm run build + - name: Lint Catalog if: always() run: pnpm run lint From 1ef553b59103ede3bec0a2a47498312a5b48b912 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 17:13:13 +0800 Subject: [PATCH 10/14] Build boxel icons and ui before lint --- .github/workflows/ci-lint.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index af980dd..6924134 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -58,11 +58,13 @@ jobs: # To faciliate linting of projects that depend on Boxel Icons if: always() run: pnpm run build + working-directory: packages/boxel-icons - name: Build Boxel UI # To faciliate linting of projects that depend on Boxel UI if: always() run: pnpm run build + working-directory: packages/boxel-ui/addon - name: Lint Catalog if: always() From 9ac6c93ab6a2eef6757df930ecb1f2d8f559dbb9 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Fri, 16 Jan 2026 17:22:32 +0800 Subject: [PATCH 11/14] use build-common-deps from root package --- .github/workflows/ci-lint.yaml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 6924134..7852ae1 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -54,17 +54,9 @@ jobs: # Boxel UI and Boxel Icons need to be built first so that their # linting outputs types are available for projects that depend on them - - name: Build Boxel Icons - # To faciliate linting of projects that depend on Boxel Icons + - name: Build common dependencies if: always() - run: pnpm run build - working-directory: packages/boxel-icons - - - name: Build Boxel UI - # To faciliate linting of projects that depend on Boxel UI - if: always() - run: pnpm run build - working-directory: packages/boxel-ui/addon + run: pnpm run build-common-deps - name: Lint Catalog if: always() From 8acf2ade007951e22a83ac9a94c55ec4010403c9 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Mon, 19 Jan 2026 19:01:19 +0800 Subject: [PATCH 12/14] Add upload image command --- cloudflare-image.gts | 22 ++ commands/upload-image.ts | 449 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 471 insertions(+) create mode 100644 cloudflare-image.gts create mode 100644 commands/upload-image.ts diff --git a/cloudflare-image.gts b/cloudflare-image.gts new file mode 100644 index 0000000..e7c0575 --- /dev/null +++ b/cloudflare-image.gts @@ -0,0 +1,22 @@ +import ImageCard from 'https://cardstack.com/base/image'; +import { contains, field } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import UrlField from 'https://cardstack.com/base/url'; + +export const CLOUDFLARE_ACCOUNT_ID = '4a94a1eb2d21bbbe160234438a49f687'; + +const CLOUDFLARE_VARIANT = 'public'; + +export class CloudflareImage extends ImageCard { + static displayName = 'Cloudflare Image'; + + @field cloudflareId = contains(StringField); + @field url = contains(UrlField, { + computeVia(this: CloudflareImage) { + if (!this.cloudflareId) { + return undefined; + } + return `https://i.boxel.site/${this.cloudflareId}/${CLOUDFLARE_VARIANT}`; + }, + }); +} diff --git a/commands/upload-image.ts b/commands/upload-image.ts new file mode 100644 index 0000000..0799fba --- /dev/null +++ b/commands/upload-image.ts @@ -0,0 +1,449 @@ +import { tracked } from '@glimmer/tracking'; + +import { isCardInstance, Command } from '@cardstack/runtime-common'; + +import { CardDef, field, contains } from 'https://cardstack.com/base/card-api'; +import UrlField from 'https://cardstack.com/base/url'; +import StringField from 'https://cardstack.com/base/string'; + +import SaveCardCommand from '@cardstack/boxel-host/commands/save-card'; +import SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy'; +import { CloudflareImage, CLOUDFLARE_ACCOUNT_ID } from '../cloudflare-image'; + +const CLOUDFLARE_IMAGE_INGEST_URL = `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v1`; +const CLOUDFLARE_DIRECT_UPLOAD_URL = `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/images/v2/direct_upload`; + +type UploadProgressStep = + | 'idle' + | 'requesting-direct-upload-url' + | 'parsing-data-uri' + | 'fetching-local-file' + | 'uploading-local-file' + | 'uploading-remote-url' + | 'saving-card' + | 'completed' + | 'error'; + +interface CloudflareUploadResponse { + success: boolean; + errors: unknown[]; + result?: { + id?: string; + uploadURL?: string; + [key: string]: unknown; + }; +} + +interface DirectUploadDetails { + uploadURL: string; + id: string; +} + +export class UploadImageInput extends CardDef { + @field sourceImageUrl = contains(UrlField); + @field targetRealmUrl = contains(StringField); +} + +export class CardIdCard extends CardDef { + @field cardId = contains(StringField); +} + +export default class UploadImageCommand extends Command< + typeof UploadImageInput, + typeof CardIdCard +> { + static actionVerb = 'Upload'; + description = + 'Uploads an image to Cloudflare Images and saves a CloudflareImage card in the specified realm'; + + @tracked progressStep: UploadProgressStep = 'idle'; + + async getInputType() { + return UploadImageInput; + } + + protected async run(input: UploadImageInput): Promise { + if (!input.sourceImageUrl) { + throw new Error('sourceImageUrl is required'); + } + if (!input.targetRealmUrl) { + throw new Error('targetRealm is required'); + } + + const sourceImageUrl = input.sourceImageUrl.trim(); + + let isBlobUrl = sourceImageUrl.startsWith('blob:'); + let isDataUri = sourceImageUrl.startsWith('data:'); + if (!isBlobUrl && !isDataUri && !sourceImageUrl.startsWith('http')) { + throw new Error('sourceImageUrl must be a valid URL'); + } + + const sendRequestCommand = new SendRequestViaProxyCommand( + this.commandContext, + ); + let cloudflareId: string; + + try { + if (isBlobUrl || isDataUri) { + this.progressStep = 'requesting-direct-upload-url'; + let { uploadURL, id } = await this.requestDirectUploadUrl( + sendRequestCommand, + ); + + let blobDetails; + if (isDataUri) { + this.progressStep = 'parsing-data-uri'; + blobDetails = this.parseDataUri(sourceImageUrl); + } else { + this.progressStep = 'fetching-local-file'; + blobDetails = await this.fetchBlobFromObjectUrl(sourceImageUrl); + } + let { blob, fileName, contentType } = blobDetails; + + this.progressStep = 'uploading-local-file'; + let uploadPayload = await this.uploadBlobToCloudflare( + uploadURL, + blob, + fileName, + contentType, + ); + + cloudflareId = uploadPayload.result?.id ?? id; + if (!cloudflareId) { + throw new Error( + 'Cloudflare upload succeeded but no image id was returned', + ); + } + } else { + this.progressStep = 'uploading-remote-url'; + let payload = await this.forwardRemoteUrl( + sendRequestCommand, + sourceImageUrl, + ); + + const remoteUploadId = payload.result?.id; + if (!remoteUploadId) { + throw new Error( + 'Cloudflare upload succeeded but no image id was returned', + ); + } + cloudflareId = remoteUploadId; + } + + this.progressStep = 'saving-card'; + + let saveCardCommand = new SaveCardCommand(this.commandContext); + let cloudflareImageCard: CloudflareImage = new CloudflareImage({ + cloudflareId, + }); + await saveCardCommand.execute({ + card: cloudflareImageCard, + realm: input.targetRealmUrl, + }); + if (!isCardInstance(cloudflareImageCard)) { + throw new Error( + `Failed to save CloudflareImage card: ${JSON.stringify(cloudflareImageCard, null, 2)}`, + ); + } + + if (!cloudflareImageCard.id) { + throw new Error('Saved CloudflareImage card does not have an id'); + } + + this.progressStep = 'completed'; + + return new CardIdCard({ + cardId: cloudflareImageCard.id, + }); + } catch (error) { + this.progressStep = 'error'; + throw error; + } + } + + private async requestDirectUploadUrl( + sendRequestCommand: SendRequestViaProxyCommand, + ): Promise { + const directUploadResult = await sendRequestCommand.execute({ + url: CLOUDFLARE_DIRECT_UPLOAD_URL, + method: 'POST', + requestBody: JSON.stringify({ + requireSignedURLs: false, + }), + multipart: true, + }); + + const responseText = await directUploadResult.response.text(); + if (!directUploadResult.response.ok) { + throw new Error( + `Cloudflare direct upload URL request failed: ${directUploadResult.response.status} - ${responseText}`, + ); + } + + let payload: CloudflareUploadResponse; + try { + payload = JSON.parse(responseText) as CloudflareUploadResponse; + } catch (parseError) { + throw new Error( + `Unable to parse Cloudflare direct upload response: ${String(parseError)}`, + ); + } + + if (!payload.success) { + throw new Error( + `Cloudflare direct upload request failed: ${JSON.stringify(payload, null, 2)}`, + ); + } + let uploadURL = payload.result?.uploadURL; + let id = payload.result?.id; + if (!uploadURL || !id) { + throw new Error( + 'Cloudflare direct upload response did not include required fields', + ); + } + + return { uploadURL, id }; + } + + private parseDataUri(dataUri: string) { + const trimmed = dataUri.trim(); + if (!trimmed.startsWith('data:')) { + throw new Error('Invalid data URI: missing data: prefix'); + } + + const commaIndex = trimmed.indexOf(','); + if (commaIndex === -1) { + throw new Error('Invalid data URI: missing data payload'); + } + + const metadataSection = trimmed.substring(5, commaIndex); // remove "data:" + const payloadSection = trimmed.substring(commaIndex + 1); + + if (!payloadSection) { + throw new Error('Invalid data URI: empty data payload'); + } + + const metadataParts = metadataSection.split(';').filter(Boolean); + + let mimeType = 'text/plain;charset=US-ASCII'; + let isBase64 = false; + let providedFileName: string | undefined; + + for (let part of metadataParts) { + if (part === 'base64') { + isBase64 = true; + continue; + } + if (part.startsWith('name=')) { + providedFileName = part.slice('name='.length); + continue; + } + if (part.includes('/')) { + mimeType = part; + } else if (part.startsWith('charset=')) { + // Preserve charset parameter when no explicit mime type provided + if (!mimeType.includes('/')) { + mimeType = `text/plain;${part}`; + } + } + } + + if (!mimeType.includes('/')) { + mimeType = 'application/octet-stream'; + } + + const cleanedPayload = payloadSection.replace(/\s+/g, ''); + if (!cleanedPayload) { + throw new Error('Invalid data URI: payload contains no data'); + } + + let byteArray: Uint8Array; + const nodeBuffer = (globalThis as any).Buffer as + | { from(input: string, encoding: string): Uint8Array } + | undefined; + if (isBase64) { + try { + if (nodeBuffer) { + byteArray = new Uint8Array( + nodeBuffer.from(cleanedPayload, 'base64'), + ); + } else if (typeof atob === 'function') { + const binaryString = atob(cleanedPayload); + byteArray = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + byteArray[i] = binaryString.charCodeAt(i); + } + } else { + throw new Error('No base64 decoder available in this environment'); + } + } catch (error) { + throw new Error(`Failed to decode base64 data URI payload: ${String(error)}`); + } + } else { + try { + const decoded = decodeURIComponent(cleanedPayload); + const TextEncoderCtor = (globalThis as any).TextEncoder as + | { new (): { encode(input: string): Uint8Array } } + | undefined; + if (TextEncoderCtor) { + byteArray = new TextEncoderCtor().encode(decoded); + } else { + byteArray = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + byteArray[i] = decoded.charCodeAt(i); + } + } + } catch (error) { + throw new Error(`Failed to decode data URI payload: ${String(error)}`); + } + } + + const blob = new Blob([byteArray], { type: mimeType }); + if (providedFileName) { + providedFileName = providedFileName.replace(/^"(.*)"$/, '$1'); + try { + providedFileName = decodeURIComponent(providedFileName); + } catch { + // ignore decoding errors and keep original value + } + } + const fileName = + providedFileName ?? this.deriveFileNameFromMimeType(mimeType); + const contentType = mimeType || 'application/octet-stream'; + + return { blob, fileName, contentType }; + } + + private deriveFileNameFromMimeType(mimeType: string) { + const extensionMap: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/avif': 'avif', + }; + + const extension = extensionMap[mimeType] ?? 'bin'; + return `upload.${extension}`; + } + + private async fetchBlobFromObjectUrl(objectUrl: string) { + let response: Response; + try { + response = await fetch(objectUrl); + } finally { + if (typeof URL.revokeObjectURL === 'function') { + try { + URL.revokeObjectURL(objectUrl); + } catch { + // Ignore - the browser may already have released the URL. + } + } + } + + if (!response.ok) { + throw new Error( + `Failed to read local file from object URL: ${response.status} ${response.statusText}`, + ); + } + const blob = await response.blob(); + + const maybeFile = blob as Blob & { name?: string }; + const fileName = + typeof maybeFile.name === 'string' && maybeFile.name + ? maybeFile.name + : 'upload'; + const contentType = blob.type || 'application/octet-stream'; + + return { blob, fileName, contentType }; + } + + private async uploadBlobToCloudflare( + uploadURL: string, + blob: Blob, + fileName: string, + contentType: string, + ) { + let fileForUpload: Blob; + if (typeof File === 'function') { + if (blob instanceof File) { + fileForUpload = blob; + } else { + fileForUpload = new File([blob], fileName, { type: contentType }); + } + } else { + fileForUpload = blob; + } + + const formData = new FormData(); + formData.append('file', fileForUpload, fileName); + + const response = await fetch(uploadURL, { + method: 'POST', + body: formData, + }); + + const responseText = await response.text(); + if (!response.ok) { + throw new Error( + `Cloudflare direct upload failed: ${response.status} - ${responseText}`, + ); + } + + let payload: CloudflareUploadResponse; + try { + payload = JSON.parse(responseText) as CloudflareUploadResponse; + } catch (parseError) { + throw new Error( + `Unable to parse Cloudflare direct upload result: ${String(parseError)}`, + ); + } + + if (!payload.success) { + throw new Error( + `Cloudflare direct upload failed: ${JSON.stringify(payload, null, 2)}`, + ); + } + + return payload; + } + + private async forwardRemoteUrl( + sendRequestCommand: SendRequestViaProxyCommand, + sourceImageUrl: string, + ) { + const proxyResult = await sendRequestCommand.execute({ + url: CLOUDFLARE_IMAGE_INGEST_URL, + method: 'POST', + requestBody: JSON.stringify({ + url: sourceImageUrl, + }), + multipart: true, + }); + + const responseText = await proxyResult.response.text(); + if (!proxyResult.response.ok) { + throw new Error( + `Cloudflare upload failed: ${proxyResult.response.status} - ${responseText}`, + ); + } + + let payload: CloudflareUploadResponse; + try { + payload = JSON.parse(responseText) as CloudflareUploadResponse; + } catch (parseError) { + throw new Error( + `Unable to parse Cloudflare upload response: ${String(parseError)}`, + ); + } + if (!payload.success) { + throw new Error( + `Cloudflare upload failed: ${JSON.stringify(payload, null, 2)}`, + ); + } + + return payload; + } + +} From 6f165bd1a9cd63210b8aeb8eac87e335d4559531 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Mon, 19 Jan 2026 19:11:03 +0800 Subject: [PATCH 13/14] Clean up lint workflow --- .github/workflows/ci-lint.yaml | 5 +---- Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-lint.yaml b/.github/workflows/ci-lint.yaml index 7852ae1..354ce73 100644 --- a/.github/workflows/ci-lint.yaml +++ b/.github/workflows/ci-lint.yaml @@ -32,6 +32,7 @@ jobs: # Copy catalog files into the monorepo's catalog-realm location - name: Copy catalog files into monorepo run: | + # TODO: Simplify this step once catalog-realm content is fully migrated out of the monorepo # Remove all files except config files we want to keep cd packages/catalog-realm find . -mindepth 1 \ @@ -45,13 +46,9 @@ jobs: # Copy catalog files cp -r boxel-catalog-src/* packages/catalog-realm/ - - echo "Files copied to catalog-realm:" - ls -la packages/catalog-realm/ - uses: ./.github/actions/init - # Boxel UI and Boxel Icons need to be built first so that their # linting outputs types are available for projects that depend on them - name: Build common dependencies diff --git a/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json b/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json index 7b43ff2..6fc97ec 100644 --- a/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json +++ b/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4.json @@ -2,7 +2,7 @@ "data": { "type": "card", "attributes": { - "name": "User Contributed ", + "name": "User Contributed", "color": null, "description": null, "thumbnailURL": null From 73a9e997eabfe91c86a3c40530ac28783f218b88 Mon Sep 17 00:00:00 2001 From: Richard Tan Date: Thu, 22 Jan 2026 14:40:17 +0800 Subject: [PATCH 14/14] Update catalog file --- catalog-app/listing/listing.gts | 51 ++++++++++++++++++++++++----- system-card/model-configuration.gts | 12 +++---- system-card/model-updater.gts | 10 +++--- system-card/openrouter-model.gts | 24 +++++++------- system-card/recommended-model.gts | 6 ++-- 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/catalog-app/listing/listing.gts b/catalog-app/listing/listing.gts index 9dc2dd9..2bca284 100644 --- a/catalog-app/listing/listing.gts +++ b/catalog-app/listing/listing.gts @@ -9,7 +9,7 @@ import { Component, instanceOf, realmURL, - type GetCardMenuItemParams, + type GetMenuItemParams, } from 'https://cardstack.com/base/card-api'; import { commandData } from 'https://cardstack.com/base/resources/command-data'; import MarkdownField from 'https://cardstack.com/base/markdown'; @@ -34,6 +34,7 @@ import { import { eq, type MenuItemOptions } from '@cardstack/boxel-ui/helpers'; import Refresh from '@cardstack/boxel-icons/refresh'; import Wand from '@cardstack/boxel-icons/wand'; +import Package from '@cardstack/boxel-icons/package'; import AppListingHeader from '../components/app-listing-header'; import ChooseRealmAction from '../components/choose-realm-action'; @@ -44,8 +45,9 @@ import { listingActions, isReady } from '../resources/listing-actions'; import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; import ListingGenerateExampleCommand from '@cardstack/boxel-host/commands/listing-generate-example'; import ListingUpdateSpecsCommand from '@cardstack/boxel-host/commands/listing-update-specs'; +import CreateListingPRCommand from '@cardstack/boxel-host/commands/create-listing-pr'; -import { getCardMenuItems } from '@cardstack/runtime-common'; +import { getMenuItems } from '@cardstack/runtime-common'; import { Publisher } from './publisher'; import { Category } from './category'; @@ -180,9 +182,9 @@ class EmbeddedTemplate extends Component {