From 7ebe1ca5f0d5428bd8c135c8ac4d1ff474903168 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Thu, 16 Apr 2026 20:34:22 +0800 Subject: [PATCH 1/7] split out listing commands --- packages/base/command.gts | 67 ++ .../catalog-app-commands.test.gts | 990 ++++++++++++++++++ .../catalog-app-test-fixtures.ts | 721 +++++++++++++ packages/host/app/commands/authed-fetch.ts | 52 + packages/host/app/commands/can-read-realm.ts | 34 + .../app/commands/execute-atomic-operations.ts | 44 + packages/host/app/commands/fetch-card-json.ts | 33 + .../app/commands/get-available-realm-urls.ts | 29 + .../app/commands/get-catalog-realm-urls.ts | 29 + .../commands/get-default-writable-realm.ts | 28 + .../host/app/commands/get-realm-of-url.ts | 33 + packages/host/app/commands/index.ts | 60 ++ .../host/app/commands/listing-action-build.ts | 6 - .../host/app/commands/listing-action-init.ts | 8 - packages/host/app/commands/listing-create.ts | 145 +-- .../app/commands/listing-generate-example.ts | 14 +- packages/host/app/commands/listing-install.ts | 111 +- packages/host/app/commands/listing-remix.ts | 20 +- .../host/app/commands/listing-update-specs.ts | 99 +- packages/host/app/commands/listing-use.ts | 10 +- .../commands/persist-module-inspector-view.ts | 34 + packages/host/app/commands/store-add.ts | 37 + packages/host/app/lib/externals.ts | 31 + 23 files changed, 2412 insertions(+), 223 deletions(-) create mode 100644 packages/catalog-realm/catalog-app-commands.test.gts create mode 100644 packages/catalog-realm/catalog-app-test-fixtures.ts create mode 100644 packages/host/app/commands/authed-fetch.ts create mode 100644 packages/host/app/commands/can-read-realm.ts create mode 100644 packages/host/app/commands/execute-atomic-operations.ts create mode 100644 packages/host/app/commands/fetch-card-json.ts create mode 100644 packages/host/app/commands/get-available-realm-urls.ts create mode 100644 packages/host/app/commands/get-catalog-realm-urls.ts create mode 100644 packages/host/app/commands/get-default-writable-realm.ts create mode 100644 packages/host/app/commands/get-realm-of-url.ts create mode 100644 packages/host/app/commands/persist-module-inspector-view.ts create mode 100644 packages/host/app/commands/store-add.ts diff --git a/packages/base/command.gts b/packages/base/command.gts index 4a15d077020..4ae81d3877a 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -142,6 +142,11 @@ export class SwitchSubmodeInput extends CardDef { @field createFile = contains(BooleanField); } +export class PersistModuleInspectorViewInput extends CardDef { + @field codePath = contains(StringField); + @field moduleInspectorView = contains(StringField); // 'schema' | 'spec' | 'preview' +} + export class SwitchSubmodeResult extends CardDef { @field codePath = contains(StringField); } @@ -509,6 +514,68 @@ export class GetAllRealmMetasResult extends CardDef { @field results = containsMany(RealmMetaField); } +export class GetAvailableRealmUrlsResult extends CardDef { + @field urls = containsMany(StringField); +} + +export class GetCatalogRealmUrlsResult extends CardDef { + @field urls = containsMany(StringField); +} + +export class FetchCardJsonInput extends CardDef { + @field url = contains(StringField); +} + +export class FetchCardJsonResult extends CardDef { + @field document = contains(JsonField); +} + +export class ExecuteAtomicOperationsInput extends CardDef { + @field realmUrl = contains(StringField); + @field operations = containsMany(JsonField); +} + +export class ExecuteAtomicOperationsResult extends CardDef { + @field results = containsMany(JsonField); +} + +export class StoreAddInput extends CardDef { + @field document = contains(JsonField); + @field realm = contains(StringField); +} + +export class GetRealmOfUrlInput extends CardDef { + @field url = contains(StringField); +} + +export class GetRealmOfUrlResult extends CardDef { + @field realmUrl = contains(StringField); // empty string if not found +} + +export class CanReadRealmInput extends CardDef { + @field realmUrl = contains(StringField); +} + +export class CanReadRealmResult extends CardDef { + @field canRead = contains(BooleanField); +} + +export class AuthedFetchInput extends CardDef { + @field url = contains(StringField); + @field method = contains(StringField); + @field acceptHeader = contains(StringField); +} + +export class AuthedFetchResult extends CardDef { + @field ok = contains(BooleanField); + @field status = contains(NumberField); + @field body = contains(JsonField); +} + +export class GetDefaultWritableRealmResult extends CardDef { + @field realmPath = contains(StringField); // empty string if no writable realm found +} + export class SearchGoogleImagesInput extends CardDef { @field query = contains(StringField); @field maxResults = contains(NumberField); // optional, default 10 diff --git a/packages/catalog-realm/catalog-app-commands.test.gts b/packages/catalog-realm/catalog-app-commands.test.gts new file mode 100644 index 00000000000..abc92dc7e4b --- /dev/null +++ b/packages/catalog-realm/catalog-app-commands.test.gts @@ -0,0 +1,990 @@ +import { waitFor, settled } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, skip, test } from 'qunit'; + +import { ensureTrailingSlash } from '@cardstack/runtime-common'; + +import ListingCreateCommand from '@cardstack/boxel-host/commands/listing-create'; +import ListingInstallCommand from '@cardstack/boxel-host/commands/listing-install'; +import ListingRemixCommand from '@cardstack/boxel-host/commands/listing-remix'; +import ListingUseCommand from '@cardstack/boxel-host/commands/listing-use'; + +import ENV from '@cardstack/host/config/environment'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; + +import { + setupLocalIndexing, + setupOnSave, + testRealmURL as mockCatalogURL, + setupAuthEndpoints, + setupUserSubscription, + setupAcceptanceTestRealm, + SYSTEM_CARD_FIXTURE_CONTENTS, + visitOperatorMode, + verifySubmode, + toggleFileTree, + openDir, + verifyFolderWithUUIDInFileTree, + verifyFileInFileTree, + verifyJSONWithUUIDInFolder, + setupRealmServerEndpoints, + setCatalogRealmURL, +} from '@cardstack/host/tests/helpers'; +import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; +import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; + +import type { CardListing } from '@cardstack/catalog/listing/listing'; + +import { + makeMockCatalogContents, + makeDestinationRealmContents, +} from './catalog-app-test-fixtures'; + +const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); +const testDestinationRealmURL = `http://test-realm/test2/`; + +//listing +const authorListingId = `${mockCatalogURL}Listing/author`; +const pirateSkillListingId = `${mockCatalogURL}SkillListing/pirate-skill`; +const apiDocumentationStubListingId = `${mockCatalogURL}Listing/api-documentation-stub`; +const themeListingId = `${mockCatalogURL}ThemeListing/cardstack-theme`; +const blogPostListingId = `${mockCatalogURL}Listing/blog-post`; +//license +const mitLicenseId = `${mockCatalogURL}License/mit`; +//category +const writingCategoryId = `${mockCatalogURL}Category/writing`; + +//tags +const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; + +export function runTests() { + module( + 'Acceptance | Catalog | catalog app - commands tests', + function (hooks) { + setupApplicationTest(hooks); + setupLocalIndexing(hooks); + setupOnSave(hooks); + + let mockMatrixUtils = setupMockMatrix(hooks, { + loggedInAs: '@testuser:localhost', + activeRealms: [mockCatalogURL, testDestinationRealmURL], + }); + + let { createAndJoinRoom } = mockMatrixUtils; + + hooks.beforeEach(async function () { + createAndJoinRoom({ + sender: '@testuser:localhost', + name: 'room-test', + }); + setupUserSubscription(); + setupAuthEndpoints(); + setCatalogRealmURL(mockCatalogURL, catalogRealmURL); + // this setup test realm is pretending to be a mock catalog + await setupAcceptanceTestRealm({ + realmURL: mockCatalogURL, + mockMatrixUtils, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), + }, + }); + await setupAcceptanceTestRealm({ + mockMatrixUtils, + realmURL: testDestinationRealmURL, + contents: { + ...SYSTEM_CARD_FIXTURE_CONTENTS, + ...makeDestinationRealmContents(), + }, + }); + }); + + /** + * Waits for a card to appear on the stack with optional title verification + */ + async function waitForCardOnStack( + cardId: string, + expectedTitle?: string, + ) { + await waitFor( + `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, + ); + if (expectedTitle) { + await waitFor( + `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, + ); + } + } + + async function executeCommand( + commandClass: + | typeof ListingUseCommand + | typeof ListingInstallCommand + | typeof ListingRemixCommand, + listingUrl: string, + realm: string, + ) { + const commandService = getService('command-service'); + const store = getService('store'); + + const command = new commandClass(commandService.commandContext); + const listing = (await store.get(listingUrl)) as CardDef; + + return command.execute({ + realm, + listing, + }); + } + + module('listing commands', function (hooks) { + hooks.beforeEach(async function () { + // we always run a command inside interact mode + await visitOperatorMode({ + stacks: [[]], + }); + }); + module('"build"', function () { + test('card listing', async function (assert) { + await visitOperatorMode({ + stacks: [ + [ + { + id: apiDocumentationStubListingId, + format: 'isolated', + }, + ], + ], + }); + await waitFor( + `[data-test-card="${apiDocumentationStubListingId}"]`, + ); + assert + .dom( + `[data-test-card="${apiDocumentationStubListingId}"] [data-test-catalog-listing-action="Build"]`, + ) + .containsText('Build', 'Build button exist in listing'); + }); + }); + module('"create"', function (hooks) { + // Mock proxy LLM endpoint only for create-related tests + setupRealmServerEndpoints(hooks, [ + { + route: '_request-forward', + getResponse: async (req: Request) => { + try { + const body = await req.json(); + if ( + body.url === 'https://openrouter.ai/api/v1/chat/completions' + ) { + let requestBody: any = {}; + try { + requestBody = body.requestBody + ? JSON.parse(body.requestBody) + : {}; + } catch { + // ignore parse failure + } + const messages = requestBody.messages || []; + const system: string = + messages.find((m: any) => m.role === 'system')?.content || + ''; + const user: string = + messages.find((m: any) => m.role === 'user')?.content || + ''; + const systemLower = system.toLowerCase(); + let content: string | undefined; + if ( + systemLower.includes( + 'respond only with one token: card, app, skill, or theme', + ) + ) { + // Heuristic moved from production code into test mock: + // If the serialized example or prompts reference an App construct + // (e.g. AppCard base class, module paths with /App/, or a name ending with App) + // then classify as 'app'. If it references Skill, classify as 'skill'. + const userLower = user.toLowerCase(); + if ( + /(appcard|blogapp|"appcard"|\.appcard|name: 'appcard')/.test( + userLower, + ) + ) { + content = 'app'; + } else if ( + /(cssvariables|css imports|theme card|themecreator|theme listing)/.test( + userLower, + ) + ) { + content = 'theme'; + } else if (/skill/.test(userLower)) { + content = 'skill'; + } else { + content = 'card'; + } + } else if (systemLower.includes('catalog listing title')) { + content = 'Mock Listing Title'; + } else if (systemLower.includes('spec-style summary')) { + content = 'Mock listing summary sentence.'; + } else if ( + systemLower.includes("boxel's sample data assistant") + ) { + content = JSON.stringify({ + examples: [ + { + label: 'Generated field value', + url: 'https://example.com/contact', + }, + ], + }); + } else if (systemLower.includes('representing tag')) { + // Deterministic tag selection + content = JSON.stringify([calculatorTagId]); + } else if (systemLower.includes('representing category')) { + // Deterministic category selection + content = JSON.stringify([writingCategoryId]); + } else if (systemLower.includes('representing license')) { + // Deterministic license selection + content = JSON.stringify([mitLicenseId]); + } + + return new Response( + JSON.stringify({ + choices: [ + { + message: { + content, + }, + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + } catch (e) { + return new Response( + JSON.stringify({ + error: 'mock forward error', + details: (e as Error).message, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + return new Response( + JSON.stringify({ error: 'Unknown proxy path' }), + { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }, + }, + ]); + test('card listing with single dependency module', async function (assert) { + const cardId = mockCatalogURL + 'author/Author/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + const result = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}author/author.gts`, + name: 'Author', + }, + targetRealm: mockCatalogURL, + }); + const interim = result?.listing as any; + assert.ok(interim, 'Interim listing exists'); + assert.strictEqual((interim as any).name, 'Mock Listing Title'); + assert.strictEqual( + (interim as any).summary, + 'Mock listing summary sentence.', + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${mockCatalogURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'CardListing/'; + await openDir(assert, instanceFolder); + const listingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (listingId) { + const listing = (await getService('store').get( + listingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + // Assertions for AI generated fields coming from proxy mock + assert.strictEqual( + (listing as any).name, + 'Mock Listing Title', + 'Listing name populated from autoPatchName mock response', + ); + assert.strictEqual( + (listing as any).summary, + 'Mock listing summary sentence.', + 'Listing summary populated from autoPatchSummary mock response', + ); + assert.strictEqual( + listing.specs.length, + 2, + 'Listing should have two specs', + ); + assert.true( + listing.specs.some((spec) => spec.ref.name === 'Author'), + 'Listing should have an Author spec', + ); + assert.true( + listing.specs.some((spec) => spec.ref.name === 'AuthorCompany'), + 'Listing should have an AuthorCompany spec', + ); + // Deterministic autoLink assertions from proxy mock + assert.ok((listing as any).license, 'License linked'); + assert.strictEqual( + (listing as any).license.id, + mitLicenseId, + 'License id matches mitLicenseId', + ); + assert.ok( + Array.isArray((listing as any).tags), + 'Tags array exists', + ); + assert.true( + (listing as any).tags.some( + (t: any) => t.id === calculatorTagId, + ), + 'Contains calculator tag id', + ); + assert.ok( + Array.isArray((listing as any).categories), + 'Categories array exists', + ); + assert.true( + (listing as any).categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Contains writing category id', + ); + } + }); + + test('listing will only create specs with recognised imports from realms it can read from', async function (assert) { + const cardId = mockCatalogURL + 'UnrecognisedImports/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}card-with-unrecognised-imports.gts`, + name: 'UnrecognisedImports', + }, + targetRealm: mockCatalogURL, + }); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${mockCatalogURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'CardListing/'; + await openDir(assert, instanceFolder); + const listingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (listingId) { + const listing = (await getService('store').get( + listingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + assert.true( + listing.specs.every( + (spec) => + spec.ref.module != + 'https://cdn.jsdelivr.net/npm/chess.js/+esm', + ), + 'Listing should does not have unrecognised import', + ); + } + }); + + test('app listing', async function (assert) { + const cardId = mockCatalogURL + 'blog-app/BlogApp/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + const createResult = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}blog-app/blog-app.gts`, + name: 'BlogApp', + }, + targetRealm: testDestinationRealmURL, + }); + // Assert store-level (in-memory) results BEFORE navigating to code mode + let immediateListing = createResult?.listing as any; + assert.ok(immediateListing, 'Listing object returned from command'); + assert.strictEqual( + immediateListing.name, + 'Mock Listing Title', + 'Immediate listing has patched name before persistence', + ); + assert.strictEqual( + immediateListing.summary, + 'Mock listing summary sentence.', + 'Immediate listing has patched summary before persistence', + ); + assert.ok( + immediateListing.license, + 'Immediate listing has linked license before persistence', + ); + assert.strictEqual( + immediateListing.license?.id, + mitLicenseId, + 'Immediate listing license id matches mitLicenseId', + ); + // Lint: avoid logical expression inside assertion + assert.ok( + Array.isArray(immediateListing.tags), + 'Immediate listing tags is an array before persistence', + ); + if (Array.isArray(immediateListing.tags)) { + assert.ok( + immediateListing.tags.length > 0, + 'Immediate listing has linked tag(s) before persistence', + ); + } + assert.true( + immediateListing.tags.some((t: any) => t.id === calculatorTagId), + 'Immediate listing includes calculator tag id', + ); + assert.ok( + Array.isArray(immediateListing.categories), + 'Immediate listing categories is an array before persistence', + ); + if (Array.isArray(immediateListing.categories)) { + assert.ok( + immediateListing.categories.length > 0, + 'Immediate listing has linked category(ies) before persistence', + ); + } + assert.true( + immediateListing.categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Immediate listing includes writing category id', + ); + assert.ok( + Array.isArray(immediateListing.specs), + 'Immediate listing specs is an array before persistence', + ); + if (Array.isArray(immediateListing.specs)) { + assert.strictEqual( + immediateListing.specs.length, + 5, + 'Immediate listing has expected number of specs before persistence', + ); + } + assert.ok( + Array.isArray(immediateListing.examples), + 'Immediate listing examples is an array before persistence', + ); + if (Array.isArray(immediateListing.examples)) { + assert.strictEqual( + immediateListing.examples.length, + 1, + 'Immediate listing has expected examples before persistence', + ); + } + // Header/title: wait for persisted id (listing.id) then assert via stack card selector + const persistedId = immediateListing.id; + assert.ok(persistedId, 'Immediate listing has a persisted id'); + await waitForCardOnStack(persistedId); + assert + .dom( + `[data-test-stack-card="${persistedId}"] [data-test-boxel-card-header-title]`, + ) + .containsText( + 'Mock Listing Title', + 'Isolated view shows patched name (persisted id)', + ); + // Summary section + assert + .dom('[data-test-catalog-listing-embedded-summary-section]') + .containsText( + 'Mock listing summary sentence.', + 'Isolated view shows patched summary', + ); + + // License section should not show fallback text + assert + .dom('[data-test-catalog-listing-embedded-license-section]') + .doesNotContainText( + 'No License Provided', + 'License section populated (autoLinkLicense)', + ); + + // Tags section + assert + .dom('[data-test-catalog-listing-embedded-tags-section]') + .doesNotContainText( + 'No Tags Provided', + 'Tags section populated (autoLinkTag)', + ); + + // Categories section + assert + .dom('[data-test-catalog-listing-embedded-categories-section]') + .doesNotContainText( + 'No Categories Provided', + 'Categories section populated (autoLinkCategory)', + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + await verifySubmode(assert, 'code'); + const instanceFolder = 'AppListing/'; + await openDir(assert, instanceFolder); + const persistedListingId = await verifyJSONWithUUIDInFolder( + assert, + instanceFolder, + ); + if (persistedListingId) { + const listing = (await getService('store').get( + persistedListingId, + )) as CardListing; + assert.ok(listing, 'Listing should be created'); + assert.strictEqual( + listing.specs.length, + 5, + 'Listing should have five specs', + ); + [ + 'Author', + 'AuthorCompany', + 'BlogPost', + 'BlogApp', + 'AppCard', + ].forEach((specName) => { + assert.true( + listing.specs.some((spec) => spec.ref.name === specName), + `Listing should have a ${specName} spec`, + ); + }); + assert.strictEqual( + listing.examples.length, + 1, + 'Listing should have one example', + ); + + // Assert autoPatch fields populated (from proxy mock responses) + assert.strictEqual( + (listing as any).name, + 'Mock Listing Title', + 'autoPatchName populated listing.name', + ); + assert.strictEqual( + (listing as any).summary, + 'Mock listing summary sentence.', + 'autoPatchSummary populated listing.summary', + ); + + // Basic object-level sanity for autoLink fields (they should exist, may be arrays) + assert.ok( + (listing as any).license, + 'autoLinkLicense populated listing.license', + ); + assert.strictEqual( + (listing as any).license?.id, + mitLicenseId, + 'Persisted listing license id matches mitLicenseId', + ); + assert.ok( + Array.isArray((listing as any).tags), + 'autoLinkTag populated listing.tags array', + ); + if (Array.isArray((listing as any).tags)) { + assert.ok( + (listing as any).tags.length > 0, + 'autoLinkTag populated listing.tags with at least one tag', + ); + } + assert.true( + (listing as any).tags.some( + (t: any) => t.id === calculatorTagId, + ), + 'Persisted listing includes calculator tag id', + ); + assert.ok( + Array.isArray((listing as any).categories), + 'autoLinkCategory populated listing.categories array', + ); + if (Array.isArray((listing as any).categories)) { + assert.ok( + (listing as any).categories.length > 0, + 'autoLinkCategory populated listing.categories with at least one category', + ); + } + assert.true( + (listing as any).categories.some( + (c: any) => c.id === writingCategoryId, + ), + 'Persisted listing includes writing category id', + ); + } + }); + + test('after create command, listing card opens on stack in interact mode', async function (assert) { + const cardId = mockCatalogURL + 'author/Author/example'; + const commandService = getService('command-service'); + const command = new ListingCreateCommand( + commandService.commandContext, + ); + + let r = await command.execute({ + openCardId: cardId, + codeRef: { + module: `${mockCatalogURL}author/author.gts`, + name: 'Author', + }, + targetRealm: mockCatalogURL, + }); + + await verifySubmode(assert, 'interact'); + const listing = r?.listing as any; + const createdId = listing.id; + assert.ok(createdId, 'Listing id should be present'); + await waitForCardOnStack(createdId); + assert + .dom(`[data-test-stack-card="${createdId}"]`) + .exists( + 'Created listing card (by persisted id) is displayed on stack after command execution', + ); + }); + }); + skip('"use"', async function () { + skip('card listing', async function (assert) { + const listingName = 'author'; + const listingId = mockCatalogURL + 'Listing/author.json'; + await executeCommand( + ListingUseCommand, + listingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let instanceFolder = `${outerFolder}Author/`; + await openDir(assert, instanceFolder); + await verifyJSONWithUUIDInFolder(assert, instanceFolder); + }); + }); + module('"install"', function () { + test('card listing', async function (assert) { + const listingName = 'author'; + + await executeCommand( + ListingInstallCommand, + authorListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + let examplePath = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, examplePath); + await verifyFileInFileTree(assert, examplePath); + }); + + test('listing installs relationships of examples and its modules', async function (assert) { + const listingName = 'blog-post'; + + await executeCommand( + ListingInstallCommand, + blogPostListingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let blogPostModulePath = `${outerFolder}blog-post/blog-post.gts`; + let authorModulePath = `${outerFolder}author/author.gts`; + await openDir(assert, blogPostModulePath); + await verifyFileInFileTree(assert, blogPostModulePath); + await openDir(assert, authorModulePath); + await verifyFileInFileTree(assert, authorModulePath); + + let blogPostExamplePath = `${outerFolder}blog-post/BlogPost/example.json`; + let authorExamplePath = `${outerFolder}author/Author/example.json`; + let authorCompanyExamplePath = `${outerFolder}author/AuthorCompany/example.json`; + await openDir(assert, blogPostExamplePath); + await verifyFileInFileTree(assert, blogPostExamplePath); + await openDir(assert, authorExamplePath); + await verifyFileInFileTree(assert, authorExamplePath); + await openDir(assert, authorCompanyExamplePath); + await verifyFileInFileTree(assert, authorCompanyExamplePath); + }); + + test('field listing', async function (assert) { + const listingName = 'contact-link'; + const contactLinkFieldListingCardId = `${mockCatalogURL}FieldListing/contact-link`; + + await executeCommand( + ListingInstallCommand, + contactLinkFieldListingCardId, + testDestinationRealmURL, + ); + + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + // contact-link-[uuid]/ + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + await openDir(assert, `${outerFolder}fields/contact-link.gts`); + let gtsFilePath = `${outerFolder}fields/contact-link.gts`; + await verifyFileInFileTree(assert, gtsFilePath); + }); + + test('skill listing', async function (assert) { + const listingName = 'pirate-skill'; + const listingId = `${mockCatalogURL}SkillListing/${listingName}`; + await executeCommand( + ListingInstallCommand, + listingId, + testDestinationRealmURL, + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}Skill/pirate-speak.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + }); + }); + module('"remix"', function () { + test('card listing: installs the card and redirects to code mode with persisted playground selection for first example successfully', async function (assert) { + const listingName = 'author'; + const listingId = `${mockCatalogURL}Listing/${listingName}`; + await visitOperatorMode({ + stacks: [[]], + }); + await executeCommand( + ListingRemixCommand, + listingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instanceFile = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, instanceFile); + await verifyFileInFileTree(assert, instanceFile); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + await settled(); + assert + .dom( + '[data-test-playground-panel] [data-test-boxel-card-header-title]', + ) + .hasText('Author - Mike Dane'); + }); + test('skill listing: installs the card and redirects to code mode with preview on first skill successfully', async function (assert) { + const listingName = 'pirate-skill'; + const listingId = `${mockCatalogURL}SkillListing/${listingName}`; + await executeCommand( + ListingRemixCommand, + listingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}Skill/pirate-speak.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let cardId = + testDestinationRealmURL + instancePath.replace('.json', ''); + await waitFor('[data-test-card-resource-loaded]'); + assert + .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) + .exists(); + }); + test('theme listing: installs the theme example and redirects to code mode successfully', async function (assert) { + const listingName = 'cardstack-theme'; + await executeCommand( + ListingRemixCommand, + themeListingId, + testDestinationRealmURL, + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}theme/theme-example.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let cardId = + testDestinationRealmURL + instancePath.replace('.json', ''); + await waitFor('[data-test-card-resource-loaded]'); + assert + .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) + .exists(); + }); + }); + + skip('"use" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + const listingId = mockCatalogURL + 'Listing/author.json'; + await executeCommand( + ListingUseCommand, + listingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let instanceFolder = `${outerFolder}Author`; + await openDir(assert, instanceFolder); + await verifyJSONWithUUIDInFolder(assert, instanceFolder); + }); + + test('"install" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + await executeCommand( + ListingInstallCommand, + authorListingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await visitOperatorMode({ + submode: 'code', + fileView: 'browser', + codePath: `${testDestinationRealmURL}index`, + }); + + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + let instancePath = `${outerFolder}${listingName}/Author/example.json`; + + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + }); + + test('"remix" is successful even if target realm does not have a trailing slash', async function (assert) { + const listingName = 'author'; + const listingId = `${mockCatalogURL}Listing/${listingName}`; + await visitOperatorMode({ + stacks: [[]], + }); + await executeCommand( + ListingRemixCommand, + listingId, + removeTrailingSlash(testDestinationRealmURL), + ); + await settled(); + await verifySubmode(assert, 'code'); + await toggleFileTree(); + let outerFolder = await verifyFolderWithUUIDInFileTree( + assert, + listingName, + ); + let instancePath = `${outerFolder}${listingName}/Author/example.json`; + await openDir(assert, instancePath); + await verifyFileInFileTree(assert, instancePath); + let gtsFilePath = `${outerFolder}${listingName}/author.gts`; + await openDir(assert, gtsFilePath); + await verifyFileInFileTree(assert, gtsFilePath); + await settled(); + assert + .dom( + '[data-test-playground-panel] [data-test-boxel-card-header-title]', + ) + .hasText('Author - Mike Dane'); + }); + }); + }, + ); +} + +function removeTrailingSlash(url: string): string { + if (url === undefined || url === null) { + throw new Error(`removeTrailingSlash called with invalid url: ${url}`); + } + return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; +} diff --git a/packages/catalog-realm/catalog-app-test-fixtures.ts b/packages/catalog-realm/catalog-app-test-fixtures.ts new file mode 100644 index 00000000000..3d0a974e170 --- /dev/null +++ b/packages/catalog-realm/catalog-app-test-fixtures.ts @@ -0,0 +1,721 @@ +export const authorCardSource = ` + import { field, contains, linksTo, CardDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + + export class AuthorCompany extends CardDef { + static displayName = 'AuthorCompany'; + @field name = contains(StringField); + @field address = contains(StringField); + @field city = contains(StringField); + @field state = contains(StringField); + @field zip = contains(StringField); + } + + export class Author extends CardDef { + static displayName = 'Author'; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Author) { + return [this.firstName, this.lastName].filter(Boolean).join(' '); + }, + }); + @field company = linksTo(AuthorCompany); + } +`; + +export const blogPostCardSource = ` + import { field, contains, CardDef, linksTo } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + import { Author } from '../author/author'; + + export class BlogPost extends CardDef { + static displayName = 'BlogPost'; + @field cardTitle = contains(StringField); + @field content = contains(StringField); + @field author = linksTo(Author); + } +`; + +export const contactLinkFieldSource = ` + import { field, contains, FieldDef } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + + export class ContactLink extends FieldDef { + static displayName = 'ContactLink'; + @field label = contains(StringField); + @field url = contains(StringField); + @field type = contains(StringField); + } +`; + +export const appCardSource = ` + import { CardDef } from 'https://cardstack.com/base/card-api'; + + export class AppCard extends CardDef { + static displayName = 'App Card'; + static prefersWideFormat = true; + } +`; + +export const blogAppCardSource = ` + import { field, contains, containsMany } from 'https://cardstack.com/base/card-api'; + import StringField from 'https://cardstack.com/base/string'; + import { AppCard } from '../app-card'; + import { BlogPost } from '../blog-post/blog-post'; + + export class BlogApp extends AppCard { + static displayName = 'Blog App'; + @field cardTitle = contains(StringField); + @field posts = containsMany(BlogPost); + } +`; + +export const cardWithUnrecognisedImports = ` + import { field, CardDef, linksTo } from 'https://cardstack.com/base/card-api'; + // External import that should be ignored by sanitizeDeps + import { Chess as _ChessJS } from 'https://cdn.jsdelivr.net/npm/chess.js/+esm'; + import { Author } from './author/author'; + + export class UnrecognisedImports extends CardDef { + static displayName = 'Unrecognised Imports'; + @field author = linksTo(Author); + } +`; + +export function makeMockCatalogContents( + mockCatalogURL: string, + catalogRealmURL: string, +): Record { + const authorCompanyExampleId = `${mockCatalogURL}author/AuthorCompany/example`; + const authorSpecId = `${mockCatalogURL}Spec/author`; + const authorExampleId = `${mockCatalogURL}author/Author/example`; + const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; + const writingCategoryId = `${mockCatalogURL}Category/writing`; + const mitLicenseId = `${mockCatalogURL}License/mit`; + const publisherId = `${mockCatalogURL}Publisher/boxel-publisher`; + const pirateSkillId = `${mockCatalogURL}Skill/pirate-speak`; + const unknownSpecId = `${mockCatalogURL}Spec/unknown-no-type`; + const stubTagId = `${mockCatalogURL}Tag/stub`; + const authorListingId = `${mockCatalogURL}Listing/author`; + + return { + 'author/author.gts': authorCardSource, + 'blog-post/blog-post.gts': blogPostCardSource, + 'fields/contact-link.gts': contactLinkFieldSource, + 'app-card.gts': appCardSource, + 'blog-app/blog-app.gts': blogAppCardSource, + 'card-with-unrecognised-imports.gts': cardWithUnrecognisedImports, + 'theme/theme-example.json': { + data: { + type: 'card', + attributes: { + cssVariables: + ':root { --background: #ffffff; } .dark { --background: #000000; }', + cssImports: [], + cardInfo: { + cardTitle: 'Sample Theme', + cardDescription: 'A sample theme for testing remix.', + cardThumbnailURL: null, + notes: null, + }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'Theme', + }, + }, + }, + }, + 'ThemeListing/cardstack-theme.json': { + data: { + meta: { + adoptsFrom: { + name: 'ThemeListing', + module: `${catalogRealmURL}catalog-app/listing/listing`, + }, + }, + type: 'card', + attributes: { + name: 'Cardstack Theme', + images: [], + summary: 'Cardstack base theme listing.', + }, + relationships: { + specs: { + links: { + self: null, + }, + }, + skills: { + links: { + self: null, + }, + }, + tags: { + links: { + self: null, + }, + }, + license: { + links: { + self: null, + }, + }, + publisher: { + links: { + self: null, + }, + }, + 'examples.0': { + links: { + self: '../theme/theme-example', + }, + }, + categories: { + links: { + self: null, + }, + }, + }, + }, + }, + 'author/Author/example.json': { + data: { + type: 'card', + attributes: { + firstName: 'Mike', + lastName: 'Dane', + summary: 'Author', + }, + relationships: { + company: { + links: { + self: authorCompanyExampleId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}author/author`, + name: 'Author', + }, + }, + }, + }, + 'author/AuthorCompany/example.json': { + data: { + type: 'card', + attributes: { + name: 'Cardstack Labs', + address: '123 Main St', + city: 'Portland', + state: 'OR', + zip: '97205', + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}author/author`, + name: 'AuthorCompany', + }, + }, + }, + }, + 'UnrecognisedImports/example.json': { + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}card-with-unrecognised-imports`, + name: 'UnrecognisedImports', + }, + }, + }, + }, + 'blog-post/BlogPost/example.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Blog Post', + content: 'Blog Post Content', + }, + relationships: { + author: { + links: { + self: authorExampleId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}blog-post/blog-post`, + name: 'BlogPost', + }, + }, + }, + }, + 'blog-app/BlogApp/example.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'My Blog App', + }, + meta: { + adoptsFrom: { + module: `${mockCatalogURL}blog-app/blog-app`, + name: 'BlogApp', + }, + }, + }, + }, + 'Spec/author.json': { + data: { + type: 'card', + attributes: { + readMe: 'This is the author spec readme', + ref: { + name: 'Author', + module: `${mockCatalogURL}author/author`, + }, + }, + specType: 'card', + containedExamples: [], + cardTitle: 'Author', + cardDescription: 'Spec for Author card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Spec/contact-link.json': { + data: { + type: 'card', + attributes: { + ref: { + name: 'ContactLink', + module: `${mockCatalogURL}fields/contact-link`, + }, + }, + specType: 'field', + containedExamples: [], + cardTitle: 'ContactLink', + cardDescription: 'Spec for ContactLink field', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Spec/unknown-no-type.json': { + data: { + type: 'card', + attributes: { + readMe: 'Spec without specType to trigger unknown grouping', + ref: { + name: 'UnknownNoType', + module: `${mockCatalogURL}unknown/unknown-no-type`, + }, + }, + // intentionally omitting specType so it falls into 'unknown' + containedExamples: [], + cardTitle: 'UnknownNoType', + cardDescription: 'Spec lacking specType', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, + 'Listing/author.json': { + data: { + type: 'card', + attributes: { + name: 'Author', + cardTitle: 'Author', // hardcoding title otherwise test will be flaky when waiting for a computed + summary: 'A card for representing an author.', + }, + relationships: { + 'specs.0': { + links: { + self: authorSpecId, + }, + }, + 'examples.0': { + links: { + self: authorExampleId, + }, + }, + 'tags.0': { + links: { + self: calculatorTagId, + }, + }, + 'categories.0': { + links: { + self: writingCategoryId, + }, + }, + license: { + links: { + self: mitLicenseId, + }, + }, + publisher: { + links: { + self: publisherId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'Listing/blog-post.json': { + data: { + type: 'card', + attributes: { + name: 'Blog Post', + cardTitle: 'Blog Post', + }, + relationships: { + 'examples.0': { + links: { + self: `${mockCatalogURL}blog-post/BlogPost/example`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'Publisher/boxel-publisher.json': { + data: { + type: 'card', + attributes: { + name: 'Boxel Publishing', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/publisher`, + name: 'Publisher', + }, + }, + }, + }, + 'License/mit.json': { + data: { + type: 'card', + attributes: { + name: 'MIT License', + content: 'MIT License', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/license`, + name: 'License', + }, + }, + }, + }, + 'Listing/person.json': { + data: { + type: 'card', + attributes: { + name: 'Person', + cardTitle: 'Person', // hardcoding title otherwise test will be flaky when waiting for a computed + images: [ + 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400', + 'https://images.unsplash.com/photo-1494790108755-2616b332db29?w=400', + 'https://images.unsplash.com/photo-1552374196-c4e7ffc6e126?w=400', + ], + }, + relationships: { + 'tags.0': { + links: { + self: calculatorTagId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'Listing/unknown-only.json': { + data: { + type: 'card', + attributes: {}, + relationships: { + 'specs.0': { + links: { + self: unknownSpecId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'AppListing/blog-app.json': { + data: { + type: 'card', + attributes: { + name: 'Blog App', + cardTitle: 'Blog App', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'AppListing', + }, + }, + }, + }, + 'Listing/empty.json': { + data: { + type: 'card', + attributes: { + name: 'Empty', + cardTitle: 'Empty', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'CardListing', + }, + }, + }, + }, + 'SkillListing/pirate-skill.json': { + data: { + type: 'card', + attributes: { + name: 'Pirate Skill', + cardTitle: 'Pirate Skill', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + relationships: { + 'skills.0': { + links: { + self: pirateSkillId, + }, + }, + }, + 'categories.0': { + links: { + self: writingCategoryId, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'SkillListing', + }, + }, + }, + }, + 'Category/writing.json': { + data: { + type: 'card', + attributes: { + name: 'Writing', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/category`, + name: 'Category', + }, + }, + }, + }, + 'Listing/incomplete-skill.json': { + data: { + type: 'card', + attributes: { + name: 'Incomplete Skill', + cardTitle: 'Incomplete Skill', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'SkillListing', + }, + }, + }, + }, + 'Skill/pirate-speak.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Talk Like a Pirate', + name: 'Pirate Speak', + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/skill', + name: 'Skill', + }, + }, + }, + }, + 'Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7.json': { + data: { + type: 'card', + attributes: { + name: 'Calculator', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/tag`, + name: 'Tag', + }, + }, + }, + }, + 'Tag/51de249c-516a-4c4d-bd88-76e88274c483.json': { + data: { + type: 'card', + attributes: { + name: 'Game', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/tag`, + name: 'Tag', + }, + }, + }, + }, + 'Tag/stub.json': { + data: { + type: 'card', + attributes: { + name: 'Stub', + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/tag`, + name: 'Tag', + }, + }, + }, + }, + 'Listing/api-documentation-stub.json': { + data: { + type: 'card', + attributes: { + name: 'API Documentation', + cardTitle: 'API Documentation', // hardcoding title otherwise test will be flaky when waiting for a computed + }, + relationships: { + 'tags.0': { + links: { + self: stubTagId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'Listing', + }, + }, + }, + }, + 'FieldListing/contact-link.json': { + data: { + type: 'card', + attributes: { + name: 'Contact Link', + cardTitle: 'Contact Link', // hardcoding title otherwise test will be flaky when waiting for a computed + summary: + 'A field for creating and managing contact links such as email, phone, or other web links.', + }, + relationships: { + 'specs.0': { + links: { + self: `${mockCatalogURL}Spec/contact-link`, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/listing/listing`, + name: 'FieldListing', + }, + }, + }, + }, + 'index.json': { + data: { + type: 'card', + attributes: {}, + relationships: { + 'startHere.0': { + links: { + self: authorListingId, + }, + }, + }, + meta: { + adoptsFrom: { + module: `${catalogRealmURL}catalog-app/catalog`, + name: 'Catalog', + }, + }, + }, + }, + '.realm.json': { + name: 'Cardstack Catalog', + backgroundURL: + 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', + iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', + }, + }; +} + +export function makeDestinationRealmContents(): Record { + return { + 'index.json': { + data: { + type: 'card', + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/cards-grid', + name: 'CardsGrid', + }, + }, + }, + }, + '.realm.json': { + name: 'Test Workspace B', + backgroundURL: + 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', + iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', + }, + }; +} diff --git a/packages/host/app/commands/authed-fetch.ts b/packages/host/app/commands/authed-fetch.ts new file mode 100644 index 00000000000..eefa68502b6 --- /dev/null +++ b/packages/host/app/commands/authed-fetch.ts @@ -0,0 +1,52 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type NetworkService from '../services/network'; + +export default class AuthedFetchCommand extends HostBaseCommand< + typeof BaseCommandModule.AuthedFetchInput, + typeof BaseCommandModule.AuthedFetchResult +> { + @service declare private network: NetworkService; + + description = 'Perform an authenticated HTTP fetch'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { AuthedFetchInput } = commandModule; + return AuthedFetchInput; + } + + requireInputFields = ['url']; + + protected async run( + input: BaseCommandModule.AuthedFetchInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { AuthedFetchResult } = commandModule; + const headers: Record = {}; + if (input.acceptHeader) { + headers['Accept'] = input.acceptHeader; + } + const response = await this.network.authedFetch(input.url, { + method: input.method ?? 'GET', + headers, + }); + let body: Record = {}; + if (response.ok) { + try { + body = await response.json(); + } catch { + // non-JSON response + } + } + return new AuthedFetchResult({ + ok: response.ok, + status: response.status, + body, + }); + } +} diff --git a/packages/host/app/commands/can-read-realm.ts b/packages/host/app/commands/can-read-realm.ts new file mode 100644 index 00000000000..eb5bb7b2094 --- /dev/null +++ b/packages/host/app/commands/can-read-realm.ts @@ -0,0 +1,34 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmService from '../services/realm'; + +export default class CanReadRealmCommand extends HostBaseCommand< + typeof BaseCommandModule.CanReadRealmInput, + typeof BaseCommandModule.CanReadRealmResult +> { + @service declare private realm: RealmService; + + description = 'Check whether the current user can read a realm'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { CanReadRealmInput } = commandModule; + return CanReadRealmInput; + } + + requireInputFields = ['realmUrl']; + + protected async run( + input: BaseCommandModule.CanReadRealmInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { CanReadRealmResult } = commandModule; + return new CanReadRealmResult({ + canRead: this.realm.canRead(input.realmUrl), + }); + } +} diff --git a/packages/host/app/commands/execute-atomic-operations.ts b/packages/host/app/commands/execute-atomic-operations.ts new file mode 100644 index 00000000000..fbc3ccbbd93 --- /dev/null +++ b/packages/host/app/commands/execute-atomic-operations.ts @@ -0,0 +1,44 @@ +import { service } from '@ember/service'; + +import type { AtomicOperation } from '@cardstack/runtime-common/atomic-document'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type CardService from '../services/card-service'; + +export default class ExecuteAtomicOperationsCommand extends HostBaseCommand< + typeof BaseCommandModule.ExecuteAtomicOperationsInput, + typeof BaseCommandModule.ExecuteAtomicOperationsResult +> { + @service declare private cardService: CardService; + + description = 'Execute atomic operations against a realm'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ExecuteAtomicOperationsInput } = commandModule; + return ExecuteAtomicOperationsInput; + } + + requireInputFields = ['realmUrl', 'operations']; + + protected async run( + input: BaseCommandModule.ExecuteAtomicOperationsInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { ExecuteAtomicOperationsResult } = commandModule; + const results = await this.cardService.executeAtomicOperations( + input.operations as AtomicOperation[], + new URL(input.realmUrl), + ); + const atomicResults = results['atomic:results']; + if (!Array.isArray(atomicResults)) { + const detail = (results as { errors?: Array<{ detail?: string }> }) + .errors?.[0]?.detail; + throw new Error(detail ?? 'Atomic operations failed'); + } + return new ExecuteAtomicOperationsResult({ results: atomicResults }); + } +} diff --git a/packages/host/app/commands/fetch-card-json.ts b/packages/host/app/commands/fetch-card-json.ts new file mode 100644 index 00000000000..0742b36cb02 --- /dev/null +++ b/packages/host/app/commands/fetch-card-json.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type CardService from '../services/card-service'; + +export default class FetchCardJsonCommand extends HostBaseCommand< + typeof BaseCommandModule.FetchCardJsonInput, + typeof BaseCommandModule.FetchCardJsonResult +> { + @service declare private cardService: CardService; + + description = 'Fetch a card as a JSON document by URL'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { FetchCardJsonInput } = commandModule; + return FetchCardJsonInput; + } + + requireInputFields = ['url']; + + protected async run( + input: BaseCommandModule.FetchCardJsonInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { FetchCardJsonResult } = commandModule; + const doc = await this.cardService.fetchJSON(input.url); + return new FetchCardJsonResult({ document: doc }); + } +} diff --git a/packages/host/app/commands/get-available-realm-urls.ts b/packages/host/app/commands/get-available-realm-urls.ts new file mode 100644 index 00000000000..8ea44b326a5 --- /dev/null +++ b/packages/host/app/commands/get-available-realm-urls.ts @@ -0,0 +1,29 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmServerService from '../services/realm-server'; + +export default class GetAvailableRealmUrlsCommand extends HostBaseCommand< + undefined, + typeof BaseCommandModule.GetAvailableRealmUrlsResult +> { + @service declare private realmServer: RealmServerService; + + static actionVerb = 'Get Realm URLs'; + description = 'Get the list of available realm URLs'; + + async getInputType() { + return undefined; + } + + protected async run(): Promise { + let commandModule = await this.loadCommandModule(); + const { GetAvailableRealmUrlsResult } = commandModule; + return new GetAvailableRealmUrlsResult({ + urls: this.realmServer.availableRealmURLs, + }); + } +} diff --git a/packages/host/app/commands/get-catalog-realm-urls.ts b/packages/host/app/commands/get-catalog-realm-urls.ts new file mode 100644 index 00000000000..1816f4121ea --- /dev/null +++ b/packages/host/app/commands/get-catalog-realm-urls.ts @@ -0,0 +1,29 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmServerService from '../services/realm-server'; + +export default class GetCatalogRealmUrlsCommand extends HostBaseCommand< + undefined, + typeof BaseCommandModule.GetCatalogRealmUrlsResult +> { + @service declare private realmServer: RealmServerService; + + static actionVerb = 'Get Catalog Realm URLs'; + description = 'Get the list of catalog realm URLs'; + + async getInputType() { + return undefined; + } + + protected async run(): Promise { + let commandModule = await this.loadCommandModule(); + const { GetCatalogRealmUrlsResult } = commandModule; + return new GetCatalogRealmUrlsResult({ + urls: this.realmServer.catalogRealmURLs, + }); + } +} diff --git a/packages/host/app/commands/get-default-writable-realm.ts b/packages/host/app/commands/get-default-writable-realm.ts new file mode 100644 index 00000000000..8dd8ba45025 --- /dev/null +++ b/packages/host/app/commands/get-default-writable-realm.ts @@ -0,0 +1,28 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmService from '../services/realm'; + +export default class GetDefaultWritableRealmCommand extends HostBaseCommand< + undefined, + typeof BaseCommandModule.GetDefaultWritableRealmResult +> { + @service declare private realm: RealmService; + + description = 'Get the path of the default writable realm'; + + async getInputType() { + return undefined; + } + + protected async run(): Promise { + let commandModule = await this.loadCommandModule(); + const { GetDefaultWritableRealmResult } = commandModule; + return new GetDefaultWritableRealmResult({ + realmPath: this.realm.defaultWritableRealm?.path ?? '', + }); + } +} diff --git a/packages/host/app/commands/get-realm-of-url.ts b/packages/host/app/commands/get-realm-of-url.ts new file mode 100644 index 00000000000..b5a1dc453b9 --- /dev/null +++ b/packages/host/app/commands/get-realm-of-url.ts @@ -0,0 +1,33 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type RealmService from '../services/realm'; + +export default class GetRealmOfUrlCommand extends HostBaseCommand< + typeof BaseCommandModule.GetRealmOfUrlInput, + typeof BaseCommandModule.GetRealmOfUrlResult +> { + @service declare private realm: RealmService; + + description = 'Get the realm URL that contains a given URL'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { GetRealmOfUrlInput } = commandModule; + return GetRealmOfUrlInput; + } + + requireInputFields = ['url']; + + protected async run( + input: BaseCommandModule.GetRealmOfUrlInput, + ): Promise { + let commandModule = await this.loadCommandModule(); + const { GetRealmOfUrlResult } = commandModule; + const realmUrl = this.realm.realmOfURL(new URL(input.url)); + return new GetRealmOfUrlResult({ realmUrl: realmUrl?.href ?? '' }); + } +} diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index ebe33dd1597..7c5552e8d7f 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -7,6 +7,8 @@ import * as ApplySearchReplaceBlockCommandModule from './apply-search-replace-bl import * as AskAiCommandModule from './ask-ai'; import * as CreateListingPRRequestCommandModule from './bot-requests/create-listing-pr-request'; import * as SendBotTriggerEventCommandModule from './bot-requests/send-bot-trigger-event'; +import * as AuthedFetchCommandModule from './authed-fetch'; +import * as CanReadRealmCommandModule from './can-read-realm'; import * as CancelIndexingJobCommandModule from './cancel-indexing-job'; import * as CheckCorrectnessCommandModule from './check-correctness'; import * as CopyAndEditCommandModule from './copy-and-edit'; @@ -18,14 +20,20 @@ import * as CreateAIAssistantRoomCommandModule from './create-ai-assistant-room' import * as CreateAndOpenSubmissionWorkflowCard from './create-and-open-submission-workflow-card'; import * as CreateSpecCommandModule from './create-specs'; import * as CreateSubmissionWorkflowCommandModule from './create-submission-workflow'; +import * as ExecuteAtomicOperationsCommandModule from './execute-atomic-operations'; +import * as FetchCardJsonCommandModule from './fetch-card-json'; import * as FullReindexRealmCommandModule from './full-reindex-realm'; import * as GenerateExampleCardsCommandModule from './generate-example-cards'; import * as GenerateReadmeSpecCommandModule from './generate-readme-spec'; import * as GenerateThemeExampleCommandModule from './generate-theme-example'; import * as GetAllRealmMetasCommandModule from './get-all-realm-metas'; +import * as GetAvailableRealmUrlsCommandModule from './get-available-realm-urls'; import * as GetCardCommandModule from './get-card'; +import * as GetCatalogRealmUrlsCommandModule from './get-catalog-realm-urls'; import * as GetCardTypeSchemaCommandModule from './get-card-type-schema'; +import * as GetDefaultWritableRealmCommandModule from './get-default-writable-realm'; import * as GetEventsFromRoomCommandModule from './get-events-from-room'; +import * as GetRealmOfUrlCommandModule from './get-realm-of-url'; import * as GetUserSystemCardCommandModule from './get-user-system-card'; import * as InvalidateRealmUrlsCommandModule from './invalidate-realm-urls'; import * as InviteUserToRoomCommandModule from './invite-user-to-room'; @@ -47,6 +55,7 @@ import * as PatchCardInstanceCommandModule from './patch-card-instance'; import * as PatchCodeCommandModule from './patch-code'; import * as PatchFieldsCommandModule from './patch-fields'; import * as PatchThemeCommandModule from './patch-theme'; +import * as PersistModuleInspectorViewCommandModule from './persist-module-inspector-view'; import * as PopulateWithSampleDataCommandModule from './populate-with-sample-data'; import * as PreviewFormatCommandModule from './preview-format'; import * as ReadCardForAiAssistantCommandModule from './read-card-for-ai-assistant'; @@ -56,6 +65,7 @@ import * as ReadTextFileCommandModule from './read-text-file'; import * as RegisterBotCommandModule from './register-bot'; import * as ReindexRealmCommandModule from './reindex-realm'; import * as SaveCardCommandModule from './save-card'; +import * as StoreAddCommandModule from './store-add'; import * as SearchAndChooseCommandModule from './search-and-choose'; import * as SearchCardsCommandModule from './search-cards'; import * as SearchGoogleImagesCommandModule from './search-google-images'; @@ -88,6 +98,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/ask-ai', AskAiCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/authed-fetch', + AuthedFetchCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/apply-markdown-edit', ApplyMarkdownEditCommandModule, @@ -136,6 +150,14 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/generate-theme-example', GenerateThemeExampleCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/execute-atomic-operations', + ExecuteAtomicOperationsCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/fetch-card-json', + FetchCardJsonCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/full-reindex-realm', FullReindexRealmCommandModule, @@ -212,6 +234,10 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/patch-theme', PatchThemeCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/persist-module-inspector-view', + PersistModuleInspectorViewCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/preview-format', PreviewFormatCommandModule, @@ -368,6 +394,30 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/get-all-realm-metas', GetAllRealmMetasCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/get-available-realm-urls', + GetAvailableRealmUrlsCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/get-catalog-realm-urls', + GetCatalogRealmUrlsCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/can-read-realm', + CanReadRealmCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/get-default-writable-realm', + GetDefaultWritableRealmCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/get-realm-of-url', + GetRealmOfUrlCommandModule, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/store-add', + StoreAddCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/get-user-system-card', GetUserSystemCardCommandModule, @@ -400,14 +450,22 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ CopyCardToStackCommandModule.default, CopyFileToRealmCommandModule.default, CopySourceCommandModule.default, + AuthedFetchCommandModule.default, + CanReadRealmCommandModule.default, CreateAIAssistantRoomCommandModule.default, CopyAndEditCommandModule.default, CreateSpecCommandModule.default, + ExecuteAtomicOperationsCommandModule.default, + FetchCardJsonCommandModule.default, FullReindexRealmCommandModule.default, GenerateExampleCardsCommandModule.default, GenerateReadmeSpecCommandModule.default, GetAllRealmMetasCommandModule.default, + GetAvailableRealmUrlsCommandModule.default, + GetDefaultWritableRealmCommandModule.default, + GetCatalogRealmUrlsCommandModule.default, GetCardCommandModule.default, + GetRealmOfUrlCommandModule.default, GetCardTypeSchemaCommandModule.default, GetUserSystemCardCommandModule.default, GetEventsFromRoomCommandModule.default, @@ -434,6 +492,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ PatchCodeCommandModule.default, PatchFieldsCommandModule.default, PatchThemeCommandModule.default, + PersistModuleInspectorViewCommandModule.default, PopulateWithSampleDataCommandModule.default, PreviewFormatCommandModule.default, ReadCardForAiAssistantCommandModule.default, @@ -443,6 +502,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ RegisterBotCommandModule.default, ReindexRealmCommandModule.default, SaveCardCommandModule.default, + StoreAddCommandModule.default, SerializeCardCommandModule.default, SearchAndChooseCommandModule.default, SearchCardsCommandModule.SearchCardsByQueryCommand, diff --git a/packages/host/app/commands/listing-action-build.ts b/packages/host/app/commands/listing-action-build.ts index bed17b6e9a6..9eb0d75a4c8 100644 --- a/packages/host/app/commands/listing-action-build.ts +++ b/packages/host/app/commands/listing-action-build.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { DEFAULT_CODING_LLM } from '@cardstack/runtime-common/matrix-constants'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; @@ -14,15 +12,11 @@ import SetActiveLLMCommand from './set-active-llm'; import SwitchSubmodeCommand from './switch-submode'; import UpdateRoomSkillsCommand from './update-room-skills'; -import type StoreService from '../services/store'; - import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; export default class ListingActionBuildCommand extends HostBaseCommand< typeof BaseCommandModule.ListingBuildInput > { - @service declare private store: StoreService; - description = 'Catalog listing build command'; async getInputType() { diff --git a/packages/host/app/commands/listing-action-init.ts b/packages/host/app/commands/listing-action-init.ts index fb8a6f11433..ec57c7df7fb 100644 --- a/packages/host/app/commands/listing-action-init.ts +++ b/packages/host/app/commands/listing-action-init.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { DEFAULT_REMIX_LLM } from '@cardstack/runtime-common/matrix-constants'; import type * as BaseCommandModule from 'https://cardstack.com/base/command'; @@ -13,17 +11,11 @@ import SendAiAssistantMessageCommand from './send-ai-assistant-message'; import SetActiveLLMCommand from './set-active-llm'; import UpdateRoomSkillsCommand from './update-room-skills'; -import type RealmServerService from '../services/realm-server'; -import type StoreService from '../services/store'; - import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; export default class ListingActionInitCommand extends HostBaseCommand< typeof BaseCommandModule.ListingActionInput > { - @service declare private realmServer: RealmServerService; - @service declare private store: StoreService; - description = 'Catalog listing use command'; async getInputType() { diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index d08771639f9..3c721af9543 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { isScopedCSSRequest } from 'glimmer-scoped-css'; import type { @@ -26,15 +24,16 @@ import type { Spec } from 'https://cardstack.com/base/spec'; import HostBaseCommand from '../lib/host-base-command'; +import AuthedFetchCommand from './authed-fetch'; +import CanReadRealmCommand from './can-read-realm'; import CreateSpecCommand from './create-specs'; +import GetCatalogRealmUrlsCommand from './get-catalog-realm-urls'; +import GetCardCommand from './get-card'; +import GetRealmOfUrlCommand from './get-realm-of-url'; import OneShotLlmRequestCommand from './one-shot-llm-request'; import SearchAndChooseCommand from './search-and-choose'; import { SearchCardsByTypeAndTitleCommand } from './search-cards'; - -import type NetworkService from '../services/network'; -import type RealmService from '../services/realm'; -import type RealmServerService from '../services/realm-server'; -import type StoreService from '../services/store'; +import StoreAddCommand from './store-add'; type ListingType = 'card' | 'skill' | 'theme' | 'field'; @@ -52,18 +51,14 @@ export default class ListingCreateCommand extends HostBaseCommand< typeof BaseCommandModule.ListingCreateInput, typeof BaseCommandModule.ListingCreateResult > { - @service declare private store: StoreService; - @service declare private network: NetworkService; - @service declare private realm: RealmService; - @service declare private realmServer: RealmServerService; - static actionVerb = 'Create'; description = 'Create a catalog listing for an example card'; - get catalogRealm() { - return this.realmServer.catalogRealmURLs.find((realm) => - realm.endsWith('/catalog/'), - ); + private async getCatalogRealm(): Promise { + const { urls } = await new GetCatalogRealmUrlsCommand( + this.commandContext, + ).execute(undefined); + return urls.find((realm: string) => realm.endsWith('/catalog/')); } async getInputType() { @@ -74,7 +69,9 @@ export default class ListingCreateCommand extends HostBaseCommand< requireInputFields = ['codeRef', 'targetRealm']; - private sanitizeModuleList(modulesToCreate: Iterable) { + private async sanitizeModuleList( + modulesToCreate: Iterable, + ): Promise { // Normalize to extensionless URLs before deduplication so that e.g. // "https://…/foo.gts" and "https://…/foo" don't produce separate entries. const seen = new Map(); // normalized → original @@ -85,30 +82,39 @@ export default class ListingCreateCommand extends HostBaseCommand< } } let uniqueModules = Array.from(seen.values()); - return uniqueModules.filter((dep) => { - // Exclude scoped CSS requests - if (isScopedCSSRequest(dep)) { - return false; - } - // Exclude known global/package/icon sources - if ( - [ - 'https://cardstack.com', - 'https://packages', - 'https://boxel-icons.boxel.ai', - ].some((urlStem) => dep.startsWith(urlStem)) - ) { - return false; - } - // Only allow modulesToCreate that belong to a realm we can read - const url = new URL(dep); - const realmURL = this.realm.realmOfURL(url); - if (!realmURL) { - return false; - } - return this.realm.canRead(realmURL.href); - }); + const results = await Promise.all( + uniqueModules.map(async (dep) => { + // Exclude scoped CSS requests + if (isScopedCSSRequest(dep)) { + return null; + } + // Exclude known global/package/icon sources + if ( + [ + 'https://cardstack.com', + 'https://packages', + 'https://boxel-icons.boxel.ai', + ].some((urlStem) => dep.startsWith(urlStem)) + ) { + return null; + } + + // Only allow modulesToCreate that belong to a realm we can read + const { realmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: dep }); + if (!realmUrl) { + return null; + } + const { canRead } = await new CanReadRealmCommand( + this.commandContext, + ).execute({ realmUrl }); + return canRead ? dep : null; + }), + ); + + return results.filter((dep): dep is string => dep !== null); } protected async run( @@ -127,6 +133,7 @@ export default class ListingCreateCommand extends HostBaseCommand< } let listingType = await this.guessListingType(codeRef); + const catalogRealm = await this.getCatalogRealm(); let relationships: Record = {}; if (openCardIds && openCardIds.length > 0) { @@ -141,13 +148,16 @@ export default class ListingCreateCommand extends HostBaseCommand< relationships, meta: { adoptsFrom: { - module: `${this.catalogRealm}catalog-app/listing/listing`, + module: `${catalogRealm}catalog-app/listing/listing`, name: listingSubClass[listingType], }, }, }, }; - const listing = await this.store.add(listingDoc, { realm: targetRealm }); + const listing = await new StoreAddCommand(this.commandContext).execute({ + document: listingDoc, + realm: targetRealm, + }); const commandModule = await this.loadCommandModule(); const listingCard = listing as CardAPI.CardDef; @@ -229,41 +239,39 @@ export default class ListingCreateCommand extends HostBaseCommand< moduleUrl: string, // the module URL of the card type being listed codeRef: ResolvedCodeRef, // the specific export being listed ): Promise { - const resourceRealm = - this.realm.realmOfURL(new URL(resourceUrl))?.href ?? targetRealm; - const url = `${resourceRealm}_dependencies?url=${encodeURIComponent(resourceUrl)}`; - const response = await this.network.authedFetch(url, { - headers: { Accept: SupportedMimeType.JSONAPI }, - }); - - if (!response.ok) { + const { realmUrl: resourceRealmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: resourceUrl }); + const resourceRealm = resourceRealmUrl || targetRealm; + const depUrl = `${resourceRealm}_dependencies?url=${encodeURIComponent(resourceUrl)}`; + const { ok, body: jsonApiResponse } = await new AuthedFetchCommand( + this.commandContext, + ).execute({ url: depUrl, acceptHeader: SupportedMimeType.JSONAPI }); + + if (!ok) { console.warn('Failed to fetch dependencies for specs'); (listing as any).specs = []; return []; } - const jsonApiResponse = (await response.json()) as { - data?: Array<{ - type: string; - id: string; - attributes?: { - dependencies?: string[]; - }; - }>; - }; - // Collect all modules (main + dependencies). Deduplication happens in sanitizeModuleList(). // The _dependencies endpoint excludes the queried resource itself, so we // explicitly include the module URL to ensure a spec is created for it. const modulesToCreate: string[] = [moduleUrl]; - jsonApiResponse.data?.forEach((entry) => { + ( + jsonApiResponse as { + data?: Array<{ + attributes?: { dependencies?: string[] }; + }>; + } + ).data?.forEach((entry) => { if (entry.attributes?.dependencies) { modulesToCreate.push(...entry.attributes.dependencies); } }); - const sanitizedModules = this.sanitizeModuleList(modulesToCreate); + const sanitizedModules = await this.sanitizeModuleList(modulesToCreate); // Create specs for all unique modules const uniqueSpecsById = new Map(); @@ -400,7 +408,9 @@ export default class ListingCreateCommand extends HostBaseCommand< await Promise.all( openCardIds.map(async (openCardId) => { try { - const instance = await this.store.get(openCardId); + const instance = await new GetCardCommand( + this.commandContext, + ).execute({ cardId: openCardId }); if (isCardInstance(instance)) { addCard(instance as CardAPI.CardDef); } else { @@ -483,9 +493,10 @@ export default class ListingCreateCommand extends HostBaseCommand< } private async autoLinkLicense(listing: CardAPI.CardDef) { + const catalogRealm = await this.getCatalogRealm(); const selected = await this.chooseCards({ candidateTypeCodeRef: { - module: `${this.catalogRealm}catalog-app/listing/license`, + module: `${catalogRealm}catalog-app/listing/license`, name: 'License', } as ResolvedCodeRef, }); @@ -496,10 +507,11 @@ export default class ListingCreateCommand extends HostBaseCommand< listing: CardAPI.CardDef, codeRef: ResolvedCodeRef, ) { + const catalogRealm = await this.getCatalogRealm(); const selected = await this.chooseCards( { candidateTypeCodeRef: { - module: `${this.catalogRealm}catalog-app/listing/tag`, + module: `${catalogRealm}catalog-app/listing/tag`, name: 'Tag', } as ResolvedCodeRef, sourceContextCodeRef: codeRef, @@ -521,10 +533,11 @@ export default class ListingCreateCommand extends HostBaseCommand< listing: CardAPI.CardDef, codeRef: ResolvedCodeRef, ) { + const catalogRealm = await this.getCatalogRealm(); const selected = await this.chooseCards( { candidateTypeCodeRef: { - module: `${this.catalogRealm}catalog-app/listing/category`, + module: `${catalogRealm}catalog-app/listing/category`, name: 'Category', } as ResolvedCodeRef, sourceContextCodeRef: codeRef, diff --git a/packages/host/app/commands/listing-generate-example.ts b/packages/host/app/commands/listing-generate-example.ts index 2e03a13c63d..fcad06dd0da 100644 --- a/packages/host/app/commands/listing-generate-example.ts +++ b/packages/host/app/commands/listing-generate-example.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { resolveAdoptsFrom } from '@cardstack/runtime-common/code-ref'; import { realmURL } from '@cardstack/runtime-common/constants'; @@ -9,15 +7,12 @@ import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import HostBaseCommand from '../lib/host-base-command'; import { GenerateExampleCardsOneShotCommand } from './generate-example-cards'; - -import type RealmService from '../services/realm'; +import GetDefaultWritableRealmCommand from './get-default-writable-realm'; export default class ListingGenerateExampleCommand extends HostBaseCommand< typeof BaseCommandModule.GenerateListingExampleInput, typeof BaseCommandModule.CreateInstanceResult > { - @service declare private realm: RealmService; - static actionVerb = 'Generate Example'; description = 'Generate a new example card for the listing and link it.'; @@ -49,11 +44,16 @@ export default class ListingGenerateExampleCommand extends HostBaseCommand< ); } + const { realmPath: defaultWritableRealmPath } = + await new GetDefaultWritableRealmCommand(this.commandContext).execute( + undefined, + ); const targetRealm = input.realm || (referenceExample as any)[realmURL]?.href || listing[realmURL]?.href || - this.realm.defaultWritableRealm?.path; + defaultWritableRealmPath || + undefined; const generator = new GenerateExampleCardsOneShotCommand( this.commandContext, diff --git a/packages/host/app/commands/listing-install.ts b/packages/host/app/commands/listing-install.ts index 907421bb5c1..2d996d93b61 100644 --- a/packages/host/app/commands/listing-install.ts +++ b/packages/host/app/commands/listing-install.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import type { ListingPathResolver, ModuleResource, @@ -13,15 +11,9 @@ import { planInstanceInstall, PlanBuilder, extractRelationshipIds, - isCardInstance, - isSingleCardDocument, type Relationship, } from '@cardstack/runtime-common'; import { logger } from '@cardstack/runtime-common'; -import type { - AtomicOperation, - AtomicOperationResult, -} from '@cardstack/runtime-common/atomic-document'; import type { CopyInstanceMeta } from '@cardstack/runtime-common/catalog'; import type { CopyModuleMeta } from '@cardstack/runtime-common/catalog'; @@ -30,9 +22,13 @@ import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import HostBaseCommand from '../lib/host-base-command'; -import type CardService from '../services/card-service'; -import type RealmServerService from '../services/realm-server'; -import type StoreService from '../services/store'; +import ExecuteAtomicOperationsCommand from './execute-atomic-operations'; +import FetchCardJsonCommand from './fetch-card-json'; +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; +import GetCardCommand from './get-card'; +import ReadSourceCommand from './read-source'; +import SerializeCardCommand from './serialize-card'; + import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; const log = logger('catalog:install'); @@ -41,10 +37,6 @@ export default class ListingInstallCommand extends HostBaseCommand< typeof BaseCommandModule.ListingInstallInput, typeof BaseCommandModule.ListingInstallResult > { - @service declare private realmServer: RealmServerService; - @service declare private cardService: CardService; - @service declare private store: StoreService; - description = 'Install catalog listing with bringing them to code mode, and then remixing them via AI'; @@ -59,11 +51,13 @@ export default class ListingInstallCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ListingInstallInput, ): Promise { - let realmUrls = this.realmServer.availableRealmURLs; let { realm, listing: listingInput } = input; let realmUrl = new RealmPaths(new URL(realm)).url; + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); if (!realmUrls.includes(realmUrl)) { throw new Error(`Invalid realm: ${realmUrl}`); } @@ -107,62 +101,45 @@ export default class ListingInstallCommand extends HostBaseCommand< let sourceOperations = await Promise.all( plan.modulesToInstall.map(async (moduleMeta: CopyModuleMeta) => { let { sourceModule, targetModule } = moduleMeta; - let res = await this.cardService.getSource(new URL(sourceModule)); + let { content } = await new ReadSourceCommand( + this.commandContext, + ).execute({ path: sourceModule }); let moduleResource: ModuleResource = { type: 'source', - attributes: { content: res.content }, + attributes: { content }, meta: {}, }; let href = targetModule + '.gts'; - return { - op: 'add' as const, - href, - data: moduleResource, - }; + return { op: 'add' as const, href, data: moduleResource }; }), ); + let instanceOperations = await Promise.all( plan.instancesCopy.map(async (copyInstanceMeta: CopyInstanceMeta) => { let { sourceCard } = copyInstanceMeta; - let doc = await this.cardService.fetchJSON(sourceCard.id); - if (!isSingleCardDocument(doc)) { + let { document: doc } = await new FetchCardJsonCommand( + this.commandContext, + ).execute({ url: sourceCard.id }); + if (!doc || !('data' in doc)) { throw new Error('We are only expecting single documents returned'); } - delete doc.data.id; - delete doc.included; - let cardResource: LooseCardResource = doc?.data; + delete (doc as any).data.id; + delete (doc as any).included; + let cardResource: LooseCardResource = (doc as any).data as LooseCardResource; let href = join(realmUrl, copyInstanceMeta.lid) + '.json'; - return { - op: 'add' as const, - href, - data: cardResource, - }; + return { op: 'add' as const, href, data: cardResource }; }), ); - let operations: AtomicOperation[] = [ - ...sourceOperations, - ...instanceOperations, - ]; + const operations = [...sourceOperations, ...instanceOperations]; - let results = await this.cardService.executeAtomicOperations( - operations, - new URL(realmUrl), - ); + const { results: atomicResults } = await new ExecuteAtomicOperationsCommand( + this.commandContext, + ).execute({ realmUrl, operations }); - let atomicResults: AtomicOperationResult[] | undefined = - results['atomic:results']; - if (!Array.isArray(atomicResults)) { - let detail = (results as { errors?: Array<{ detail?: string }> }) - .errors?.[0]?.detail; - if (detail?.includes('filter refers to a nonexistent type')) { - throw new Error( - 'Please click "Update Specs" on the listing and make sure all specs are linked.', - ); - } - throw new Error(detail); - } - let writtenFiles = atomicResults.map((r) => r.data.id); + let writtenFiles = (atomicResults as Array>).map( + (r) => r.data?.id, + ); log.debug('=== Final Results ==='); log.debug(JSON.stringify(writtenFiles, null, 2)); @@ -193,23 +170,17 @@ export default class ListingInstallCommand extends HostBaseCommand< } visited.add(id); - let cachedInstance = this.store.peek(id); - let relationships: Record = {}; - let baseUrl = id; - let instance = isCardInstance(cachedInstance) - ? cachedInstance - : await this.store.get(id); - if (!isCardInstance(instance)) { - throw new Error(`Expected card instance for ${id}`); - } + let instance = (await new GetCardCommand(this.commandContext).execute({ + cardId: id, + })) as CardDef; instancesById.set(instance.id ?? id, instance); - let serialized = await this.cardService.serializeCard(instance, { - omitQueryFields: true, - }); - if (serialized.data.id) { - baseUrl = serialized.data.id; - } - relationships = serialized.data.relationships ?? {}; + + let { json: serialized } = await new SerializeCardCommand( + this.commandContext, + ).execute({ cardId: id }); + let baseUrl: string = (serialized as any)?.data?.id ?? id; + let relationships: Record = + (serialized as any)?.data?.relationships ?? {}; let entries = Object.entries(relationships); log.debug(`Relationships for ${id}:`); diff --git a/packages/host/app/commands/listing-remix.ts b/packages/host/app/commands/listing-remix.ts index c8a15eb9dba..fb8006219ba 100644 --- a/packages/host/app/commands/listing-remix.ts +++ b/packages/host/app/commands/listing-remix.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { isResolvedCodeRef, RealmPaths, @@ -14,20 +12,18 @@ import HostBaseCommand from '../lib/host-base-command'; import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; import UseAiAssistantCommand from './ai-assistant'; +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; import ListingInstallCommand from './listing-install'; +import PersistModuleInspectorViewCommand from './persist-module-inspector-view'; import SwitchSubmodeCommand from './switch-submode'; import UpdateCodePathWithSelectionCommand from './update-code-path-with-selection'; import UpdatePlaygroundSelectionCommand from './update-playground-selection'; -import type OperatorModeStateService from '../services/operator-mode-state-service'; -import type RealmServerService from '../services/realm-server'; import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; export default class RemixCommand extends HostBaseCommand< typeof BaseCommandModule.ListingInstallInput > { - @service declare private operatorModeStateService: OperatorModeStateService; - @service declare private realmServer: RealmServerService; static actionVerb = 'Remix'; @@ -80,9 +76,11 @@ export default class RemixCommand extends HostBaseCommand< }, ); - this.operatorModeStateService.persistModuleInspectorView( - codePath + '.gts', - 'preview', + await new PersistModuleInspectorViewCommand(this.commandContext).execute( + { + codePath: codePath + '.gts', + moduleInspectorView: 'preview', + }, ); } @@ -112,11 +110,13 @@ export default class RemixCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ListingInstallInput, ): Promise { - let realmUrls = this.realmServer.availableRealmURLs; let { realm, listing: listingInput } = input; let realmUrl = new RealmPaths(new URL(realm)).url; // Make sure realm is valid + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); if (!realmUrls.includes(realmUrl)) { throw new Error(`Invalid realm: ${realmUrl}`); } diff --git a/packages/host/app/commands/listing-update-specs.ts b/packages/host/app/commands/listing-update-specs.ts index 7e284171a77..3c6e773114d 100644 --- a/packages/host/app/commands/listing-update-specs.ts +++ b/packages/host/app/commands/listing-update-specs.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { isScopedCSSRequest } from 'glimmer-scoped-css'; import { isCardInstance, SupportedMimeType } from '@cardstack/runtime-common'; @@ -11,18 +9,15 @@ import type { Spec } from 'https://cardstack.com/base/spec'; import HostBaseCommand from '../lib/host-base-command'; +import AuthedFetchCommand from './authed-fetch'; +import CanReadRealmCommand from './can-read-realm'; import CreateSpecCommand from './create-specs'; - -import type NetworkService from '../services/network'; -import type RealmService from '../services/realm'; +import GetRealmOfUrlCommand from './get-realm-of-url'; export default class ListingUpdateSpecsCommand extends HostBaseCommand< typeof BaseCommandModule.ListingUpdateSpecsInput, typeof BaseCommandModule.ListingUpdateSpecsResult > { - @service declare private network: NetworkService; - @service declare private realm: RealmService; - static actionVerb = 'Update'; description = 'Update listing specs based on example dependencies'; requireInputFields = ['listing']; @@ -33,31 +28,38 @@ export default class ListingUpdateSpecsCommand extends HostBaseCommand< return ListingUpdateSpecsInput; } - private sanitizeDeps(deps: string[]) { - return deps.filter((dep) => { - if (isScopedCSSRequest(dep)) { - return false; - } - if ( - [ - 'https://cardstack.com', - 'https://packages', - 'https://boxel-icons.boxel.ai', - ].some((urlStem) => dep.startsWith(urlStem)) - ) { - return false; - } - try { - const url = new URL(dep); - const realmURL = this.realm.realmOfURL(url); - if (!realmURL) { - return false; + private async sanitizeDeps(deps: string[]): Promise { + const results = await Promise.all( + deps.map(async (dep) => { + if (isScopedCSSRequest(dep)) { + return null; } - return this.realm.canRead(realmURL.href); - } catch { - return false; - } - }); + if ( + [ + 'https://cardstack.com', + 'https://packages', + 'https://boxel-icons.boxel.ai', + ].some((urlStem) => dep.startsWith(urlStem)) + ) { + return null; + } + try { + const { realmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: dep }); + if (!realmUrl) { + return null; + } + const { canRead } = await new CanReadRealmCommand( + this.commandContext, + ).execute({ realmUrl }); + return canRead ? dep : null; + } catch { + return null; + } + }), + ); + return results.filter((dep): dep is string => dep !== null); } protected async run( @@ -82,28 +84,25 @@ export default class ListingUpdateSpecsCommand extends HostBaseCommand< throw new Error('No example found in listing to derive specs from'); } - const response = await this.network.authedFetch( - `${targetRealm}_dependencies?url=${encodeURIComponent(exampleId)}`, - { headers: { Accept: SupportedMimeType.JSONAPI } }, - ); - if (!response.ok) { + const { ok, body: jsonApiResponse } = await new AuthedFetchCommand( + this.commandContext, + ).execute({ + url: `${targetRealm}_dependencies?url=${encodeURIComponent(exampleId)}`, + acceptHeader: SupportedMimeType.JSONAPI, + }); + if (!ok) { throw new Error('Failed to fetch dependencies for listing'); } - const jsonApiResponse = (await response.json()) as { - data?: Array<{ - type: string; - id: string; - attributes?: { - dependencies?: string[]; - }; - }>; - }; - // Extract dependencies from all entries in the JSONAPI response const deps: string[] = []; - if (jsonApiResponse.data && Array.isArray(jsonApiResponse.data)) { - for (const entry of jsonApiResponse.data) { + const responseData = ( + jsonApiResponse as { + data?: Array<{ attributes?: { dependencies?: string[] } }>; + } + ).data; + if (responseData && Array.isArray(responseData)) { + for (const entry of responseData) { if ( entry.attributes?.dependencies && Array.isArray(entry.attributes.dependencies) @@ -113,7 +112,7 @@ export default class ListingUpdateSpecsCommand extends HostBaseCommand< } } - const sanitizedDeps = this.sanitizeDeps(deps); + const sanitizedDeps = await this.sanitizeDeps(deps); const commandModule = await this.loadCommandModule(); if (!sanitizedDeps.length) { (listing as any).specs = []; diff --git a/packages/host/app/commands/listing-use.ts b/packages/host/app/commands/listing-use.ts index 8ed65b72c49..9b42f5d475d 100644 --- a/packages/host/app/commands/listing-use.ts +++ b/packages/host/app/commands/listing-use.ts @@ -1,5 +1,3 @@ -import { service } from '@ember/service'; - import { cardIdToURL, codeRefWithAbsoluteURL, @@ -17,16 +15,14 @@ import type { Skill } from 'https://cardstack.com/base/skill'; import HostBaseCommand from '../lib/host-base-command'; import CopyCardToRealmCommand from './copy-card'; +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; import SaveCardCommand from './save-card'; -import type RealmServerService from '../services/realm-server'; import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; export default class ListingUseCommand extends HostBaseCommand< typeof BaseCommandModule.ListingInstallInput > { - @service declare private realmServer: RealmServerService; - description = 'Catalog listing use command'; async getInputType() { @@ -40,7 +36,6 @@ export default class ListingUseCommand extends HostBaseCommand< protected async run( input: BaseCommandModule.ListingInstallInput, ): Promise { - let realmUrls = this.realmServer.availableRealmURLs; let { realm, listing: listingInput } = input; const listing = listingInput as Listing; @@ -48,6 +43,9 @@ export default class ListingUseCommand extends HostBaseCommand< let realmUrl = new RealmPaths(new URL(realm)).url; // Make sure realm is valid + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); if (!realmUrls.includes(realmUrl)) { throw new Error(`Invalid realm: ${realmUrl}`); } diff --git a/packages/host/app/commands/persist-module-inspector-view.ts b/packages/host/app/commands/persist-module-inspector-view.ts new file mode 100644 index 00000000000..25e67c43c0b --- /dev/null +++ b/packages/host/app/commands/persist-module-inspector-view.ts @@ -0,0 +1,34 @@ +import { service } from '@ember/service'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type OperatorModeStateService from '../services/operator-mode-state-service'; + +export default class PersistModuleInspectorViewCommand extends HostBaseCommand< + typeof BaseCommandModule.PersistModuleInspectorViewInput, + undefined +> { + @service declare private operatorModeStateService: OperatorModeStateService; + + description = 'Persist the module inspector view selection to local storage'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { PersistModuleInspectorViewInput } = commandModule; + return PersistModuleInspectorViewInput; + } + + requireInputFields = ['codePath', 'moduleInspectorView']; + + protected async run( + input: BaseCommandModule.PersistModuleInspectorViewInput, + ): Promise { + this.operatorModeStateService.persistModuleInspectorView( + input.codePath, + input.moduleInspectorView as 'schema' | 'spec' | 'preview', + ); + return undefined; + } +} diff --git a/packages/host/app/commands/store-add.ts b/packages/host/app/commands/store-add.ts new file mode 100644 index 00000000000..0688abc80e1 --- /dev/null +++ b/packages/host/app/commands/store-add.ts @@ -0,0 +1,37 @@ +import { service } from '@ember/service'; + +import type { LooseSingleCardDocument } from '@cardstack/runtime-common'; + +import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import type StoreService from '../services/store'; + +export default class StoreAddCommand extends HostBaseCommand< + typeof BaseCommandModule.StoreAddInput, + typeof CardDef +> { + @service declare private store: StoreService; + + description = 'Add a card document to the store'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { StoreAddInput } = commandModule; + return StoreAddInput; + } + + requireInputFields = ['document']; + + protected async run( + input: BaseCommandModule.StoreAddInput, + ): Promise { + const result = await this.store.add( + input.document as LooseSingleCardDocument, + input.realm ? { realm: input.realm } : undefined, + ); + return result as CardDef; + } +} diff --git a/packages/host/app/lib/externals.ts b/packages/host/app/lib/externals.ts index b27222e05c6..101f1da7362 100644 --- a/packages/host/app/lib/externals.ts +++ b/packages/host/app/lib/externals.ts @@ -54,6 +54,24 @@ import * as boxelUiHelpers from '@cardstack/boxel-ui/helpers'; import * as boxelUiIcons from '@cardstack/boxel-ui/icons'; import * as boxelUiModifiers from '@cardstack/boxel-ui/modifiers'; +// Individual @cardstack/boxel-icons used by catalog-realm card modules that +// are rendered during live tests. The @cardstack/boxel-icons package exports +// each icon as a separate module — unlike @cardstack/boxel-ui/icons (an +// aggregate), these must be shimmed individually. +import * as boxelIconBuildingBank from '@cardstack/boxel-icons/building-bank'; +import * as boxelIconBuilding from '@cardstack/boxel-icons/building'; +import * as boxelIconCaptions from '@cardstack/boxel-icons/captions'; +import * as boxelIconCategory from '@cardstack/boxel-icons/category'; +import * as boxelIconHealthRecognition from '@cardstack/boxel-icons/health-recognition'; +import * as boxelIconImage from '@cardstack/boxel-icons/image'; +import * as boxelIconLayoutGridPlus from '@cardstack/boxel-icons/layout-grid-plus'; +import * as boxelIconPackage from '@cardstack/boxel-icons/package'; +import * as boxelIconRefresh from '@cardstack/boxel-icons/refresh'; +import * as boxelIconTag from '@cardstack/boxel-icons/tag'; +import * as boxelIconUsers from '@cardstack/boxel-icons/users'; +import * as boxelIconWand from '@cardstack/boxel-icons/wand'; +import * as boxelIconWorld from '@cardstack/boxel-icons/world'; + import * as runtime from '@cardstack/runtime-common'; import type { VirtualNetwork } from '@cardstack/runtime-common'; @@ -80,6 +98,19 @@ export function shimExternals(virtualNetwork: VirtualNetwork) { virtualNetwork.shimModule('@cardstack/boxel-ui/helpers', boxelUiHelpers); virtualNetwork.shimModule('@cardstack/boxel-ui/icons', boxelUiIcons); virtualNetwork.shimModule('@cardstack/boxel-ui/modifiers', boxelUiModifiers); + virtualNetwork.shimModule('@cardstack/boxel-icons/building-bank', boxelIconBuildingBank); + virtualNetwork.shimModule('@cardstack/boxel-icons/building', boxelIconBuilding); + virtualNetwork.shimModule('@cardstack/boxel-icons/captions', boxelIconCaptions); + virtualNetwork.shimModule('@cardstack/boxel-icons/category', boxelIconCategory); + virtualNetwork.shimModule('@cardstack/boxel-icons/health-recognition', boxelIconHealthRecognition); + virtualNetwork.shimModule('@cardstack/boxel-icons/image', boxelIconImage); + virtualNetwork.shimModule('@cardstack/boxel-icons/layout-grid-plus', boxelIconLayoutGridPlus); + virtualNetwork.shimModule('@cardstack/boxel-icons/package', boxelIconPackage); + virtualNetwork.shimModule('@cardstack/boxel-icons/refresh', boxelIconRefresh); + virtualNetwork.shimModule('@cardstack/boxel-icons/tag', boxelIconTag); + virtualNetwork.shimModule('@cardstack/boxel-icons/users', boxelIconUsers); + virtualNetwork.shimModule('@cardstack/boxel-icons/wand', boxelIconWand); + virtualNetwork.shimModule('@cardstack/boxel-icons/world', boxelIconWorld); virtualNetwork.shimModule('@glimmer/component', glimmerComponent); virtualNetwork.shimModule('@glimmer/tracking', glimmerTracking); virtualNetwork.shimModule('@ember/component', emberComponent); From cb912af7d0eff353fd9c1885065eec3bb9556277 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 17 Apr 2026 10:51:45 +0800 Subject: [PATCH 2/7] add commands --- packages/base/command.gts | 18 ++++- .../commands/get-default-writable-realm.ts | 2 +- packages/host/app/commands/index.ts | 12 +++ packages/host/app/commands/listing-create.ts | 51 ++---------- .../app/commands/listing-generate-example.ts | 2 +- packages/host/app/commands/listing-install.ts | 12 +-- packages/host/app/commands/listing-remix.ts | 13 +-- .../host/app/commands/listing-update-specs.ts | 40 ++-------- packages/host/app/commands/listing-use.ts | 13 +-- .../host/app/commands/sanitize-module-list.ts | 80 +++++++++++++++++++ packages/host/app/commands/validate-realm.ts | 40 ++++++++++ 11 files changed, 170 insertions(+), 113 deletions(-) create mode 100644 packages/host/app/commands/sanitize-module-list.ts create mode 100644 packages/host/app/commands/validate-realm.ts diff --git a/packages/base/command.gts b/packages/base/command.gts index 4ae81d3877a..88d5aed9c11 100644 --- a/packages/base/command.gts +++ b/packages/base/command.gts @@ -573,7 +573,23 @@ export class AuthedFetchResult extends CardDef { } export class GetDefaultWritableRealmResult extends CardDef { - @field realmPath = contains(StringField); // empty string if no writable realm found + @field realmUrl = contains(StringField); // empty string if no writable realm found +} + +export class ValidateRealmInput extends CardDef { + @field realmUrl = contains(StringField); +} + +export class ValidateRealmResult extends CardDef { + @field realmUrl = contains(StringField); // normalized with trailing slash +} + +export class SanitizeModuleListInput extends CardDef { + @field moduleUrls = containsMany(StringField); +} + +export class SanitizeModuleListResult extends CardDef { + @field moduleUrls = containsMany(StringField); } export class SearchGoogleImagesInput extends CardDef { diff --git a/packages/host/app/commands/get-default-writable-realm.ts b/packages/host/app/commands/get-default-writable-realm.ts index 8dd8ba45025..da07f630c3b 100644 --- a/packages/host/app/commands/get-default-writable-realm.ts +++ b/packages/host/app/commands/get-default-writable-realm.ts @@ -22,7 +22,7 @@ export default class GetDefaultWritableRealmCommand extends HostBaseCommand< let commandModule = await this.loadCommandModule(); const { GetDefaultWritableRealmResult } = commandModule; return new GetDefaultWritableRealmResult({ - realmPath: this.realm.defaultWritableRealm?.path ?? '', + realmUrl: this.realm.defaultWritableRealm?.path ?? '', }); } } diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index 7c5552e8d7f..ab7e61ce435 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -64,6 +64,7 @@ import * as ReadSourceCommandModule from './read-source'; import * as ReadTextFileCommandModule from './read-text-file'; import * as RegisterBotCommandModule from './register-bot'; import * as ReindexRealmCommandModule from './reindex-realm'; +import * as SanitizeModuleListCommandModule from './sanitize-module-list'; import * as SaveCardCommandModule from './save-card'; import * as StoreAddCommandModule from './store-add'; import * as SearchAndChooseCommandModule from './search-and-choose'; @@ -84,6 +85,7 @@ import * as UnregisterBotCommandModule from './unregister-bot'; import * as UpdateCodePathWithSelectionCommandModule from './update-code-path-with-selection'; import * as UpdatePlaygroundSelectionCommandModule from './update-playground-selection'; import * as UpdateRoomSkillsCommandModule from './update-room-skills'; +import * as ValidateRealmCommandModule from './validate-realm'; import * as CommandUtilsModule from './utils'; import * as WriteTextFileCommandModule from './write-text-file'; @@ -414,10 +416,18 @@ export function shimHostCommands(virtualNetwork: VirtualNetwork) { '@cardstack/boxel-host/commands/get-realm-of-url', GetRealmOfUrlCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/sanitize-module-list', + SanitizeModuleListCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/store-add', StoreAddCommandModule, ); + virtualNetwork.shimModule( + '@cardstack/boxel-host/commands/validate-realm', + ValidateRealmCommandModule, + ); virtualNetwork.shimModule( '@cardstack/boxel-host/commands/get-user-system-card', GetUserSystemCardCommandModule, @@ -502,6 +512,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ RegisterBotCommandModule.default, ReindexRealmCommandModule.default, SaveCardCommandModule.default, + SanitizeModuleListCommandModule.default, StoreAddCommandModule.default, SerializeCardCommandModule.default, SearchAndChooseCommandModule.default, @@ -526,5 +537,6 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ UpdatePlaygroundSelectionCommandModule.default, UpdateRoomSkillsCommandModule.default, UseAiAssistantCommandModule.default, + ValidateRealmCommandModule.default, WriteTextFileCommandModule.default, ]; diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index 3c721af9543..63239ad09ba 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -1,5 +1,3 @@ -import { isScopedCSSRequest } from 'glimmer-scoped-css'; - import type { LooseSingleCardDocument, ResolvedCodeRef, @@ -25,12 +23,12 @@ import type { Spec } from 'https://cardstack.com/base/spec'; import HostBaseCommand from '../lib/host-base-command'; import AuthedFetchCommand from './authed-fetch'; -import CanReadRealmCommand from './can-read-realm'; import CreateSpecCommand from './create-specs'; import GetCatalogRealmUrlsCommand from './get-catalog-realm-urls'; import GetCardCommand from './get-card'; import GetRealmOfUrlCommand from './get-realm-of-url'; import OneShotLlmRequestCommand from './one-shot-llm-request'; +import SanitizeModuleListCommand from './sanitize-module-list'; import SearchAndChooseCommand from './search-and-choose'; import { SearchCardsByTypeAndTitleCommand } from './search-cards'; import StoreAddCommand from './store-add'; @@ -72,49 +70,10 @@ export default class ListingCreateCommand extends HostBaseCommand< private async sanitizeModuleList( modulesToCreate: Iterable, ): Promise { - // Normalize to extensionless URLs before deduplication so that e.g. - // "https://…/foo.gts" and "https://…/foo" don't produce separate entries. - const seen = new Map(); // normalized → original - for (const m of modulesToCreate) { - const normalized = trimExecutableExtension(new URL(m)).href; - if (!seen.has(normalized)) { - seen.set(normalized, m); - } - } - let uniqueModules = Array.from(seen.values()); - - const results = await Promise.all( - uniqueModules.map(async (dep) => { - // Exclude scoped CSS requests - if (isScopedCSSRequest(dep)) { - return null; - } - // Exclude known global/package/icon sources - if ( - [ - 'https://cardstack.com', - 'https://packages', - 'https://boxel-icons.boxel.ai', - ].some((urlStem) => dep.startsWith(urlStem)) - ) { - return null; - } - - // Only allow modulesToCreate that belong to a realm we can read - const { realmUrl } = await new GetRealmOfUrlCommand( - this.commandContext, - ).execute({ url: dep }); - if (!realmUrl) { - return null; - } - const { canRead } = await new CanReadRealmCommand( - this.commandContext, - ).execute({ realmUrl }); - return canRead ? dep : null; - }), - ); - - return results.filter((dep): dep is string => dep !== null); + const { moduleUrls } = await new SanitizeModuleListCommand( + this.commandContext, + ).execute({ moduleUrls: Array.from(modulesToCreate) }); + return moduleUrls; } protected async run( diff --git a/packages/host/app/commands/listing-generate-example.ts b/packages/host/app/commands/listing-generate-example.ts index fcad06dd0da..93a2e035957 100644 --- a/packages/host/app/commands/listing-generate-example.ts +++ b/packages/host/app/commands/listing-generate-example.ts @@ -44,7 +44,7 @@ export default class ListingGenerateExampleCommand extends HostBaseCommand< ); } - const { realmPath: defaultWritableRealmPath } = + const { realmUrl: defaultWritableRealmPath } = await new GetDefaultWritableRealmCommand(this.commandContext).execute( undefined, ); diff --git a/packages/host/app/commands/listing-install.ts b/packages/host/app/commands/listing-install.ts index 2d996d93b61..55d8718214d 100644 --- a/packages/host/app/commands/listing-install.ts +++ b/packages/host/app/commands/listing-install.ts @@ -5,7 +5,6 @@ import type { } from '@cardstack/runtime-common'; import { type ResolvedCodeRef, - RealmPaths, join, planModuleInstall, planInstanceInstall, @@ -24,10 +23,10 @@ import HostBaseCommand from '../lib/host-base-command'; import ExecuteAtomicOperationsCommand from './execute-atomic-operations'; import FetchCardJsonCommand from './fetch-card-json'; -import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; import GetCardCommand from './get-card'; import ReadSourceCommand from './read-source'; import SerializeCardCommand from './serialize-card'; +import ValidateRealmCommand from './validate-realm'; import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; @@ -53,14 +52,9 @@ export default class ListingInstallCommand extends HostBaseCommand< ): Promise { let { realm, listing: listingInput } = input; - let realmUrl = new RealmPaths(new URL(realm)).url; - - let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + let { realmUrl } = await new ValidateRealmCommand( this.commandContext, - ).execute(undefined); - if (!realmUrls.includes(realmUrl)) { - throw new Error(`Invalid realm: ${realmUrl}`); - } + ).execute({ realmUrl: realm }); // this is intentionally to type because base command cannot interpret Listing type from catalog const listing = listingInput as Listing; diff --git a/packages/host/app/commands/listing-remix.ts b/packages/host/app/commands/listing-remix.ts index fb8006219ba..b05d27d7e0e 100644 --- a/packages/host/app/commands/listing-remix.ts +++ b/packages/host/app/commands/listing-remix.ts @@ -1,6 +1,5 @@ import { isResolvedCodeRef, - RealmPaths, type ResolvedCodeRef, } from '@cardstack/runtime-common'; import { DEFAULT_CODING_LLM } from '@cardstack/runtime-common/matrix-constants'; @@ -12,8 +11,8 @@ import HostBaseCommand from '../lib/host-base-command'; import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; import UseAiAssistantCommand from './ai-assistant'; -import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; import ListingInstallCommand from './listing-install'; +import ValidateRealmCommand from './validate-realm'; import PersistModuleInspectorViewCommand from './persist-module-inspector-view'; import SwitchSubmodeCommand from './switch-submode'; import UpdateCodePathWithSelectionCommand from './update-code-path-with-selection'; @@ -111,15 +110,9 @@ export default class RemixCommand extends HostBaseCommand< input: BaseCommandModule.ListingInstallInput, ): Promise { let { realm, listing: listingInput } = input; - let realmUrl = new RealmPaths(new URL(realm)).url; - - // Make sure realm is valid - let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + let { realmUrl } = await new ValidateRealmCommand( this.commandContext, - ).execute(undefined); - if (!realmUrls.includes(realmUrl)) { - throw new Error(`Invalid realm: ${realmUrl}`); - } + ).execute({ realmUrl: realm }); // this is intentionally to type because base command cannot interpret Listing type from catalog const listing = listingInput as Listing; diff --git a/packages/host/app/commands/listing-update-specs.ts b/packages/host/app/commands/listing-update-specs.ts index 3c6e773114d..b4dd3ff0a59 100644 --- a/packages/host/app/commands/listing-update-specs.ts +++ b/packages/host/app/commands/listing-update-specs.ts @@ -1,5 +1,3 @@ -import { isScopedCSSRequest } from 'glimmer-scoped-css'; - import { isCardInstance, SupportedMimeType } from '@cardstack/runtime-common'; import { realmURL as realmURLSymbol } from '@cardstack/runtime-common'; @@ -10,9 +8,8 @@ import type { Spec } from 'https://cardstack.com/base/spec'; import HostBaseCommand from '../lib/host-base-command'; import AuthedFetchCommand from './authed-fetch'; -import CanReadRealmCommand from './can-read-realm'; import CreateSpecCommand from './create-specs'; -import GetRealmOfUrlCommand from './get-realm-of-url'; +import SanitizeModuleListCommand from './sanitize-module-list'; export default class ListingUpdateSpecsCommand extends HostBaseCommand< typeof BaseCommandModule.ListingUpdateSpecsInput, @@ -29,37 +26,10 @@ export default class ListingUpdateSpecsCommand extends HostBaseCommand< } private async sanitizeDeps(deps: string[]): Promise { - const results = await Promise.all( - deps.map(async (dep) => { - if (isScopedCSSRequest(dep)) { - return null; - } - if ( - [ - 'https://cardstack.com', - 'https://packages', - 'https://boxel-icons.boxel.ai', - ].some((urlStem) => dep.startsWith(urlStem)) - ) { - return null; - } - try { - const { realmUrl } = await new GetRealmOfUrlCommand( - this.commandContext, - ).execute({ url: dep }); - if (!realmUrl) { - return null; - } - const { canRead } = await new CanReadRealmCommand( - this.commandContext, - ).execute({ realmUrl }); - return canRead ? dep : null; - } catch { - return null; - } - }), - ); - return results.filter((dep): dep is string => dep !== null); + const { moduleUrls } = await new SanitizeModuleListCommand( + this.commandContext, + ).execute({ moduleUrls: deps }); + return moduleUrls; } protected async run( diff --git a/packages/host/app/commands/listing-use.ts b/packages/host/app/commands/listing-use.ts index 9b42f5d475d..9fa2ef2af8b 100644 --- a/packages/host/app/commands/listing-use.ts +++ b/packages/host/app/commands/listing-use.ts @@ -4,7 +4,6 @@ import { isResolvedCodeRef, loadCardDef, generateInstallFolderName, - RealmPaths, } from '@cardstack/runtime-common'; import type * as CardAPI from 'https://cardstack.com/base/card-api'; @@ -15,8 +14,8 @@ import type { Skill } from 'https://cardstack.com/base/skill'; import HostBaseCommand from '../lib/host-base-command'; import CopyCardToRealmCommand from './copy-card'; -import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; import SaveCardCommand from './save-card'; +import ValidateRealmCommand from './validate-realm'; import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; @@ -40,15 +39,9 @@ export default class ListingUseCommand extends HostBaseCommand< const listing = listingInput as Listing; - let realmUrl = new RealmPaths(new URL(realm)).url; - - // Make sure realm is valid - let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + let { realmUrl } = await new ValidateRealmCommand( this.commandContext, - ).execute(undefined); - if (!realmUrls.includes(realmUrl)) { - throw new Error(`Invalid realm: ${realmUrl}`); - } + ).execute({ realmUrl: realm }); const specsToCopy = listing.specs ?? []; const specsWithoutFields = specsToCopy.filter( diff --git a/packages/host/app/commands/sanitize-module-list.ts b/packages/host/app/commands/sanitize-module-list.ts new file mode 100644 index 00000000000..e3d96b7ace6 --- /dev/null +++ b/packages/host/app/commands/sanitize-module-list.ts @@ -0,0 +1,80 @@ +import { isScopedCSSRequest } from 'glimmer-scoped-css'; + +import { trimExecutableExtension } from '@cardstack/runtime-common'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import CanReadRealmCommand from './can-read-realm'; +import GetRealmOfUrlCommand from './get-realm-of-url'; + +const GLOBAL_URL_STEMS = [ + 'https://cardstack.com', + 'https://packages', + 'https://boxel-icons.boxel.ai', +]; + +export default class SanitizeModuleListCommand extends HostBaseCommand< + typeof BaseCommandModule.SanitizeModuleListInput, + typeof BaseCommandModule.SanitizeModuleListResult +> { + description = + 'Filter and deduplicate a list of module URLs, removing globals and unreadable realms'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { SanitizeModuleListInput } = commandModule; + return SanitizeModuleListInput; + } + + requireInputFields = ['moduleUrls']; + + protected async run( + input: BaseCommandModule.SanitizeModuleListInput, + ): Promise { + // Normalize to extensionless URLs before deduplication so that e.g. + // "https://…/foo.gts" and "https://…/foo" don't produce separate entries. + const seen = new Map(); // normalized → original + for (const m of input.moduleUrls) { + const normalized = trimExecutableExtension(new URL(m)).href; + if (!seen.has(normalized)) { + seen.set(normalized, m); + } + } + let uniqueModules = Array.from(seen.values()); + + const results = await Promise.all( + uniqueModules.map(async (dep) => { + // Exclude scoped CSS requests + if (isScopedCSSRequest(dep)) { + return null; + } + // Exclude known global/package/icon sources + if (GLOBAL_URL_STEMS.some((urlStem) => dep.startsWith(urlStem))) { + return null; + } + + // Only allow modules that belong to a realm we can read + const { realmUrl } = await new GetRealmOfUrlCommand( + this.commandContext, + ).execute({ url: dep }); + if (!realmUrl) { + return null; + } + const { canRead } = await new CanReadRealmCommand( + this.commandContext, + ).execute({ realmUrl }); + return canRead ? dep : null; + }), + ); + + const moduleUrls = results.filter( + (dep): dep is string => dep !== null, + ); + + let commandModule = await this.loadCommandModule(); + const { SanitizeModuleListResult } = commandModule; + return new SanitizeModuleListResult({ moduleUrls }); + } +} diff --git a/packages/host/app/commands/validate-realm.ts b/packages/host/app/commands/validate-realm.ts new file mode 100644 index 00000000000..7887e2c2afd --- /dev/null +++ b/packages/host/app/commands/validate-realm.ts @@ -0,0 +1,40 @@ +import { RealmPaths } from '@cardstack/runtime-common'; + +import type * as BaseCommandModule from 'https://cardstack.com/base/command'; + +import HostBaseCommand from '../lib/host-base-command'; + +import GetAvailableRealmUrlsCommand from './get-available-realm-urls'; + +export default class ValidateRealmCommand extends HostBaseCommand< + typeof BaseCommandModule.ValidateRealmInput, + typeof BaseCommandModule.ValidateRealmResult +> { + description = 'Validate that a realm URL is available and normalize it'; + + async getInputType() { + let commandModule = await this.loadCommandModule(); + const { ValidateRealmInput } = commandModule; + return ValidateRealmInput; + } + + requireInputFields = ['realmUrl']; + + protected async run( + input: BaseCommandModule.ValidateRealmInput, + ): Promise { + let realmUrl = new RealmPaths(new URL(input.realmUrl)).url; + + let { urls: realmUrls } = await new GetAvailableRealmUrlsCommand( + this.commandContext, + ).execute(undefined); + + if (!realmUrls.includes(realmUrl)) { + throw new Error(`Invalid realm: ${realmUrl}`); + } + + let commandModule = await this.loadCommandModule(); + const { ValidateRealmResult } = commandModule; + return new ValidateRealmResult({ realmUrl }); + } +} From eade780af9237f70439dd1a08468036105a491e2 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 17 Apr 2026 11:31:07 +0800 Subject: [PATCH 3/7] remove catalog test --- .../catalog-app-commands.test.gts | 990 ------------------ .../catalog-app-test-fixtures.ts | 721 ------------- 2 files changed, 1711 deletions(-) delete mode 100644 packages/catalog-realm/catalog-app-commands.test.gts delete mode 100644 packages/catalog-realm/catalog-app-test-fixtures.ts diff --git a/packages/catalog-realm/catalog-app-commands.test.gts b/packages/catalog-realm/catalog-app-commands.test.gts deleted file mode 100644 index abc92dc7e4b..00000000000 --- a/packages/catalog-realm/catalog-app-commands.test.gts +++ /dev/null @@ -1,990 +0,0 @@ -import { waitFor, settled } from '@ember/test-helpers'; - -import { getService } from '@universal-ember/test-support'; -import { module, skip, test } from 'qunit'; - -import { ensureTrailingSlash } from '@cardstack/runtime-common'; - -import ListingCreateCommand from '@cardstack/boxel-host/commands/listing-create'; -import ListingInstallCommand from '@cardstack/boxel-host/commands/listing-install'; -import ListingRemixCommand from '@cardstack/boxel-host/commands/listing-remix'; -import ListingUseCommand from '@cardstack/boxel-host/commands/listing-use'; - -import ENV from '@cardstack/host/config/environment'; - -import type { CardDef } from 'https://cardstack.com/base/card-api'; - -import { - setupLocalIndexing, - setupOnSave, - testRealmURL as mockCatalogURL, - setupAuthEndpoints, - setupUserSubscription, - setupAcceptanceTestRealm, - SYSTEM_CARD_FIXTURE_CONTENTS, - visitOperatorMode, - verifySubmode, - toggleFileTree, - openDir, - verifyFolderWithUUIDInFileTree, - verifyFileInFileTree, - verifyJSONWithUUIDInFolder, - setupRealmServerEndpoints, - setCatalogRealmURL, -} from '@cardstack/host/tests/helpers'; -import { setupMockMatrix } from '@cardstack/host/tests/helpers/mock-matrix'; -import { setupApplicationTest } from '@cardstack/host/tests/helpers/setup'; - -import type { CardListing } from '@cardstack/catalog/listing/listing'; - -import { - makeMockCatalogContents, - makeDestinationRealmContents, -} from './catalog-app-test-fixtures'; - -const catalogRealmURL = ensureTrailingSlash(ENV.resolvedCatalogRealmURL); -const testDestinationRealmURL = `http://test-realm/test2/`; - -//listing -const authorListingId = `${mockCatalogURL}Listing/author`; -const pirateSkillListingId = `${mockCatalogURL}SkillListing/pirate-skill`; -const apiDocumentationStubListingId = `${mockCatalogURL}Listing/api-documentation-stub`; -const themeListingId = `${mockCatalogURL}ThemeListing/cardstack-theme`; -const blogPostListingId = `${mockCatalogURL}Listing/blog-post`; -//license -const mitLicenseId = `${mockCatalogURL}License/mit`; -//category -const writingCategoryId = `${mockCatalogURL}Category/writing`; - -//tags -const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; - -export function runTests() { - module( - 'Acceptance | Catalog | catalog app - commands tests', - function (hooks) { - setupApplicationTest(hooks); - setupLocalIndexing(hooks); - setupOnSave(hooks); - - let mockMatrixUtils = setupMockMatrix(hooks, { - loggedInAs: '@testuser:localhost', - activeRealms: [mockCatalogURL, testDestinationRealmURL], - }); - - let { createAndJoinRoom } = mockMatrixUtils; - - hooks.beforeEach(async function () { - createAndJoinRoom({ - sender: '@testuser:localhost', - name: 'room-test', - }); - setupUserSubscription(); - setupAuthEndpoints(); - setCatalogRealmURL(mockCatalogURL, catalogRealmURL); - // this setup test realm is pretending to be a mock catalog - await setupAcceptanceTestRealm({ - realmURL: mockCatalogURL, - mockMatrixUtils, - contents: { - ...SYSTEM_CARD_FIXTURE_CONTENTS, - ...makeMockCatalogContents(mockCatalogURL, catalogRealmURL), - }, - }); - await setupAcceptanceTestRealm({ - mockMatrixUtils, - realmURL: testDestinationRealmURL, - contents: { - ...SYSTEM_CARD_FIXTURE_CONTENTS, - ...makeDestinationRealmContents(), - }, - }); - }); - - /** - * Waits for a card to appear on the stack with optional title verification - */ - async function waitForCardOnStack( - cardId: string, - expectedTitle?: string, - ) { - await waitFor( - `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, - ); - if (expectedTitle) { - await waitFor( - `[data-test-stack-card="${cardId}"] [data-test-boxel-card-header-title]`, - ); - } - } - - async function executeCommand( - commandClass: - | typeof ListingUseCommand - | typeof ListingInstallCommand - | typeof ListingRemixCommand, - listingUrl: string, - realm: string, - ) { - const commandService = getService('command-service'); - const store = getService('store'); - - const command = new commandClass(commandService.commandContext); - const listing = (await store.get(listingUrl)) as CardDef; - - return command.execute({ - realm, - listing, - }); - } - - module('listing commands', function (hooks) { - hooks.beforeEach(async function () { - // we always run a command inside interact mode - await visitOperatorMode({ - stacks: [[]], - }); - }); - module('"build"', function () { - test('card listing', async function (assert) { - await visitOperatorMode({ - stacks: [ - [ - { - id: apiDocumentationStubListingId, - format: 'isolated', - }, - ], - ], - }); - await waitFor( - `[data-test-card="${apiDocumentationStubListingId}"]`, - ); - assert - .dom( - `[data-test-card="${apiDocumentationStubListingId}"] [data-test-catalog-listing-action="Build"]`, - ) - .containsText('Build', 'Build button exist in listing'); - }); - }); - module('"create"', function (hooks) { - // Mock proxy LLM endpoint only for create-related tests - setupRealmServerEndpoints(hooks, [ - { - route: '_request-forward', - getResponse: async (req: Request) => { - try { - const body = await req.json(); - if ( - body.url === 'https://openrouter.ai/api/v1/chat/completions' - ) { - let requestBody: any = {}; - try { - requestBody = body.requestBody - ? JSON.parse(body.requestBody) - : {}; - } catch { - // ignore parse failure - } - const messages = requestBody.messages || []; - const system: string = - messages.find((m: any) => m.role === 'system')?.content || - ''; - const user: string = - messages.find((m: any) => m.role === 'user')?.content || - ''; - const systemLower = system.toLowerCase(); - let content: string | undefined; - if ( - systemLower.includes( - 'respond only with one token: card, app, skill, or theme', - ) - ) { - // Heuristic moved from production code into test mock: - // If the serialized example or prompts reference an App construct - // (e.g. AppCard base class, module paths with /App/, or a name ending with App) - // then classify as 'app'. If it references Skill, classify as 'skill'. - const userLower = user.toLowerCase(); - if ( - /(appcard|blogapp|"appcard"|\.appcard|name: 'appcard')/.test( - userLower, - ) - ) { - content = 'app'; - } else if ( - /(cssvariables|css imports|theme card|themecreator|theme listing)/.test( - userLower, - ) - ) { - content = 'theme'; - } else if (/skill/.test(userLower)) { - content = 'skill'; - } else { - content = 'card'; - } - } else if (systemLower.includes('catalog listing title')) { - content = 'Mock Listing Title'; - } else if (systemLower.includes('spec-style summary')) { - content = 'Mock listing summary sentence.'; - } else if ( - systemLower.includes("boxel's sample data assistant") - ) { - content = JSON.stringify({ - examples: [ - { - label: 'Generated field value', - url: 'https://example.com/contact', - }, - ], - }); - } else if (systemLower.includes('representing tag')) { - // Deterministic tag selection - content = JSON.stringify([calculatorTagId]); - } else if (systemLower.includes('representing category')) { - // Deterministic category selection - content = JSON.stringify([writingCategoryId]); - } else if (systemLower.includes('representing license')) { - // Deterministic license selection - content = JSON.stringify([mitLicenseId]); - } - - return new Response( - JSON.stringify({ - choices: [ - { - message: { - content, - }, - }, - ], - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - } catch (e) { - return new Response( - JSON.stringify({ - error: 'mock forward error', - details: (e as Error).message, - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }, - ); - } - return new Response( - JSON.stringify({ error: 'Unknown proxy path' }), - { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }, - ); - }, - }, - ]); - test('card listing with single dependency module', async function (assert) { - const cardId = mockCatalogURL + 'author/Author/example'; - const commandService = getService('command-service'); - const command = new ListingCreateCommand( - commandService.commandContext, - ); - const result = await command.execute({ - openCardId: cardId, - codeRef: { - module: `${mockCatalogURL}author/author.gts`, - name: 'Author', - }, - targetRealm: mockCatalogURL, - }); - const interim = result?.listing as any; - assert.ok(interim, 'Interim listing exists'); - assert.strictEqual((interim as any).name, 'Mock Listing Title'); - assert.strictEqual( - (interim as any).summary, - 'Mock listing summary sentence.', - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${mockCatalogURL}index`, - }); - await verifySubmode(assert, 'code'); - const instanceFolder = 'CardListing/'; - await openDir(assert, instanceFolder); - const listingId = await verifyJSONWithUUIDInFolder( - assert, - instanceFolder, - ); - if (listingId) { - const listing = (await getService('store').get( - listingId, - )) as CardListing; - assert.ok(listing, 'Listing should be created'); - // Assertions for AI generated fields coming from proxy mock - assert.strictEqual( - (listing as any).name, - 'Mock Listing Title', - 'Listing name populated from autoPatchName mock response', - ); - assert.strictEqual( - (listing as any).summary, - 'Mock listing summary sentence.', - 'Listing summary populated from autoPatchSummary mock response', - ); - assert.strictEqual( - listing.specs.length, - 2, - 'Listing should have two specs', - ); - assert.true( - listing.specs.some((spec) => spec.ref.name === 'Author'), - 'Listing should have an Author spec', - ); - assert.true( - listing.specs.some((spec) => spec.ref.name === 'AuthorCompany'), - 'Listing should have an AuthorCompany spec', - ); - // Deterministic autoLink assertions from proxy mock - assert.ok((listing as any).license, 'License linked'); - assert.strictEqual( - (listing as any).license.id, - mitLicenseId, - 'License id matches mitLicenseId', - ); - assert.ok( - Array.isArray((listing as any).tags), - 'Tags array exists', - ); - assert.true( - (listing as any).tags.some( - (t: any) => t.id === calculatorTagId, - ), - 'Contains calculator tag id', - ); - assert.ok( - Array.isArray((listing as any).categories), - 'Categories array exists', - ); - assert.true( - (listing as any).categories.some( - (c: any) => c.id === writingCategoryId, - ), - 'Contains writing category id', - ); - } - }); - - test('listing will only create specs with recognised imports from realms it can read from', async function (assert) { - const cardId = mockCatalogURL + 'UnrecognisedImports/example'; - const commandService = getService('command-service'); - const command = new ListingCreateCommand( - commandService.commandContext, - ); - await command.execute({ - openCardId: cardId, - codeRef: { - module: `${mockCatalogURL}card-with-unrecognised-imports.gts`, - name: 'UnrecognisedImports', - }, - targetRealm: mockCatalogURL, - }); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${mockCatalogURL}index`, - }); - await verifySubmode(assert, 'code'); - const instanceFolder = 'CardListing/'; - await openDir(assert, instanceFolder); - const listingId = await verifyJSONWithUUIDInFolder( - assert, - instanceFolder, - ); - if (listingId) { - const listing = (await getService('store').get( - listingId, - )) as CardListing; - assert.ok(listing, 'Listing should be created'); - assert.true( - listing.specs.every( - (spec) => - spec.ref.module != - 'https://cdn.jsdelivr.net/npm/chess.js/+esm', - ), - 'Listing should does not have unrecognised import', - ); - } - }); - - test('app listing', async function (assert) { - const cardId = mockCatalogURL + 'blog-app/BlogApp/example'; - const commandService = getService('command-service'); - const command = new ListingCreateCommand( - commandService.commandContext, - ); - const createResult = await command.execute({ - openCardId: cardId, - codeRef: { - module: `${mockCatalogURL}blog-app/blog-app.gts`, - name: 'BlogApp', - }, - targetRealm: testDestinationRealmURL, - }); - // Assert store-level (in-memory) results BEFORE navigating to code mode - let immediateListing = createResult?.listing as any; - assert.ok(immediateListing, 'Listing object returned from command'); - assert.strictEqual( - immediateListing.name, - 'Mock Listing Title', - 'Immediate listing has patched name before persistence', - ); - assert.strictEqual( - immediateListing.summary, - 'Mock listing summary sentence.', - 'Immediate listing has patched summary before persistence', - ); - assert.ok( - immediateListing.license, - 'Immediate listing has linked license before persistence', - ); - assert.strictEqual( - immediateListing.license?.id, - mitLicenseId, - 'Immediate listing license id matches mitLicenseId', - ); - // Lint: avoid logical expression inside assertion - assert.ok( - Array.isArray(immediateListing.tags), - 'Immediate listing tags is an array before persistence', - ); - if (Array.isArray(immediateListing.tags)) { - assert.ok( - immediateListing.tags.length > 0, - 'Immediate listing has linked tag(s) before persistence', - ); - } - assert.true( - immediateListing.tags.some((t: any) => t.id === calculatorTagId), - 'Immediate listing includes calculator tag id', - ); - assert.ok( - Array.isArray(immediateListing.categories), - 'Immediate listing categories is an array before persistence', - ); - if (Array.isArray(immediateListing.categories)) { - assert.ok( - immediateListing.categories.length > 0, - 'Immediate listing has linked category(ies) before persistence', - ); - } - assert.true( - immediateListing.categories.some( - (c: any) => c.id === writingCategoryId, - ), - 'Immediate listing includes writing category id', - ); - assert.ok( - Array.isArray(immediateListing.specs), - 'Immediate listing specs is an array before persistence', - ); - if (Array.isArray(immediateListing.specs)) { - assert.strictEqual( - immediateListing.specs.length, - 5, - 'Immediate listing has expected number of specs before persistence', - ); - } - assert.ok( - Array.isArray(immediateListing.examples), - 'Immediate listing examples is an array before persistence', - ); - if (Array.isArray(immediateListing.examples)) { - assert.strictEqual( - immediateListing.examples.length, - 1, - 'Immediate listing has expected examples before persistence', - ); - } - // Header/title: wait for persisted id (listing.id) then assert via stack card selector - const persistedId = immediateListing.id; - assert.ok(persistedId, 'Immediate listing has a persisted id'); - await waitForCardOnStack(persistedId); - assert - .dom( - `[data-test-stack-card="${persistedId}"] [data-test-boxel-card-header-title]`, - ) - .containsText( - 'Mock Listing Title', - 'Isolated view shows patched name (persisted id)', - ); - // Summary section - assert - .dom('[data-test-catalog-listing-embedded-summary-section]') - .containsText( - 'Mock listing summary sentence.', - 'Isolated view shows patched summary', - ); - - // License section should not show fallback text - assert - .dom('[data-test-catalog-listing-embedded-license-section]') - .doesNotContainText( - 'No License Provided', - 'License section populated (autoLinkLicense)', - ); - - // Tags section - assert - .dom('[data-test-catalog-listing-embedded-tags-section]') - .doesNotContainText( - 'No Tags Provided', - 'Tags section populated (autoLinkTag)', - ); - - // Categories section - assert - .dom('[data-test-catalog-listing-embedded-categories-section]') - .doesNotContainText( - 'No Categories Provided', - 'Categories section populated (autoLinkCategory)', - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - await verifySubmode(assert, 'code'); - const instanceFolder = 'AppListing/'; - await openDir(assert, instanceFolder); - const persistedListingId = await verifyJSONWithUUIDInFolder( - assert, - instanceFolder, - ); - if (persistedListingId) { - const listing = (await getService('store').get( - persistedListingId, - )) as CardListing; - assert.ok(listing, 'Listing should be created'); - assert.strictEqual( - listing.specs.length, - 5, - 'Listing should have five specs', - ); - [ - 'Author', - 'AuthorCompany', - 'BlogPost', - 'BlogApp', - 'AppCard', - ].forEach((specName) => { - assert.true( - listing.specs.some((spec) => spec.ref.name === specName), - `Listing should have a ${specName} spec`, - ); - }); - assert.strictEqual( - listing.examples.length, - 1, - 'Listing should have one example', - ); - - // Assert autoPatch fields populated (from proxy mock responses) - assert.strictEqual( - (listing as any).name, - 'Mock Listing Title', - 'autoPatchName populated listing.name', - ); - assert.strictEqual( - (listing as any).summary, - 'Mock listing summary sentence.', - 'autoPatchSummary populated listing.summary', - ); - - // Basic object-level sanity for autoLink fields (they should exist, may be arrays) - assert.ok( - (listing as any).license, - 'autoLinkLicense populated listing.license', - ); - assert.strictEqual( - (listing as any).license?.id, - mitLicenseId, - 'Persisted listing license id matches mitLicenseId', - ); - assert.ok( - Array.isArray((listing as any).tags), - 'autoLinkTag populated listing.tags array', - ); - if (Array.isArray((listing as any).tags)) { - assert.ok( - (listing as any).tags.length > 0, - 'autoLinkTag populated listing.tags with at least one tag', - ); - } - assert.true( - (listing as any).tags.some( - (t: any) => t.id === calculatorTagId, - ), - 'Persisted listing includes calculator tag id', - ); - assert.ok( - Array.isArray((listing as any).categories), - 'autoLinkCategory populated listing.categories array', - ); - if (Array.isArray((listing as any).categories)) { - assert.ok( - (listing as any).categories.length > 0, - 'autoLinkCategory populated listing.categories with at least one category', - ); - } - assert.true( - (listing as any).categories.some( - (c: any) => c.id === writingCategoryId, - ), - 'Persisted listing includes writing category id', - ); - } - }); - - test('after create command, listing card opens on stack in interact mode', async function (assert) { - const cardId = mockCatalogURL + 'author/Author/example'; - const commandService = getService('command-service'); - const command = new ListingCreateCommand( - commandService.commandContext, - ); - - let r = await command.execute({ - openCardId: cardId, - codeRef: { - module: `${mockCatalogURL}author/author.gts`, - name: 'Author', - }, - targetRealm: mockCatalogURL, - }); - - await verifySubmode(assert, 'interact'); - const listing = r?.listing as any; - const createdId = listing.id; - assert.ok(createdId, 'Listing id should be present'); - await waitForCardOnStack(createdId); - assert - .dom(`[data-test-stack-card="${createdId}"]`) - .exists( - 'Created listing card (by persisted id) is displayed on stack after command execution', - ); - }); - }); - skip('"use"', async function () { - skip('card listing', async function (assert) { - const listingName = 'author'; - const listingId = mockCatalogURL + 'Listing/author.json'; - await executeCommand( - ListingUseCommand, - listingId, - testDestinationRealmURL, - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - - let instanceFolder = `${outerFolder}Author/`; - await openDir(assert, instanceFolder); - await verifyJSONWithUUIDInFolder(assert, instanceFolder); - }); - }); - module('"install"', function () { - test('card listing', async function (assert) { - const listingName = 'author'; - - await executeCommand( - ListingInstallCommand, - authorListingId, - testDestinationRealmURL, - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let gtsFilePath = `${outerFolder}${listingName}/author.gts`; - await openDir(assert, gtsFilePath); - await verifyFileInFileTree(assert, gtsFilePath); - let examplePath = `${outerFolder}${listingName}/Author/example.json`; - await openDir(assert, examplePath); - await verifyFileInFileTree(assert, examplePath); - }); - - test('listing installs relationships of examples and its modules', async function (assert) { - const listingName = 'blog-post'; - - await executeCommand( - ListingInstallCommand, - blogPostListingId, - testDestinationRealmURL, - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let blogPostModulePath = `${outerFolder}blog-post/blog-post.gts`; - let authorModulePath = `${outerFolder}author/author.gts`; - await openDir(assert, blogPostModulePath); - await verifyFileInFileTree(assert, blogPostModulePath); - await openDir(assert, authorModulePath); - await verifyFileInFileTree(assert, authorModulePath); - - let blogPostExamplePath = `${outerFolder}blog-post/BlogPost/example.json`; - let authorExamplePath = `${outerFolder}author/Author/example.json`; - let authorCompanyExamplePath = `${outerFolder}author/AuthorCompany/example.json`; - await openDir(assert, blogPostExamplePath); - await verifyFileInFileTree(assert, blogPostExamplePath); - await openDir(assert, authorExamplePath); - await verifyFileInFileTree(assert, authorExamplePath); - await openDir(assert, authorCompanyExamplePath); - await verifyFileInFileTree(assert, authorCompanyExamplePath); - }); - - test('field listing', async function (assert) { - const listingName = 'contact-link'; - const contactLinkFieldListingCardId = `${mockCatalogURL}FieldListing/contact-link`; - - await executeCommand( - ListingInstallCommand, - contactLinkFieldListingCardId, - testDestinationRealmURL, - ); - - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - - // contact-link-[uuid]/ - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - await openDir(assert, `${outerFolder}fields/contact-link.gts`); - let gtsFilePath = `${outerFolder}fields/contact-link.gts`; - await verifyFileInFileTree(assert, gtsFilePath); - }); - - test('skill listing', async function (assert) { - const listingName = 'pirate-skill'; - const listingId = `${mockCatalogURL}SkillListing/${listingName}`; - await executeCommand( - ListingInstallCommand, - listingId, - testDestinationRealmURL, - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let instancePath = `${outerFolder}Skill/pirate-speak.json`; - await openDir(assert, instancePath); - await verifyFileInFileTree(assert, instancePath); - }); - }); - module('"remix"', function () { - test('card listing: installs the card and redirects to code mode with persisted playground selection for first example successfully', async function (assert) { - const listingName = 'author'; - const listingId = `${mockCatalogURL}Listing/${listingName}`; - await visitOperatorMode({ - stacks: [[]], - }); - await executeCommand( - ListingRemixCommand, - listingId, - testDestinationRealmURL, - ); - await settled(); - await verifySubmode(assert, 'code'); - await toggleFileTree(); - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let instanceFile = `${outerFolder}${listingName}/Author/example.json`; - await openDir(assert, instanceFile); - await verifyFileInFileTree(assert, instanceFile); - let gtsFilePath = `${outerFolder}${listingName}/author.gts`; - await openDir(assert, gtsFilePath); - await verifyFileInFileTree(assert, gtsFilePath); - await settled(); - assert - .dom( - '[data-test-playground-panel] [data-test-boxel-card-header-title]', - ) - .hasText('Author - Mike Dane'); - }); - test('skill listing: installs the card and redirects to code mode with preview on first skill successfully', async function (assert) { - const listingName = 'pirate-skill'; - const listingId = `${mockCatalogURL}SkillListing/${listingName}`; - await executeCommand( - ListingRemixCommand, - listingId, - testDestinationRealmURL, - ); - await settled(); - await verifySubmode(assert, 'code'); - await toggleFileTree(); - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let instancePath = `${outerFolder}Skill/pirate-speak.json`; - await openDir(assert, instancePath); - await verifyFileInFileTree(assert, instancePath); - let cardId = - testDestinationRealmURL + instancePath.replace('.json', ''); - await waitFor('[data-test-card-resource-loaded]'); - assert - .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) - .exists(); - }); - test('theme listing: installs the theme example and redirects to code mode successfully', async function (assert) { - const listingName = 'cardstack-theme'; - await executeCommand( - ListingRemixCommand, - themeListingId, - testDestinationRealmURL, - ); - await settled(); - await verifySubmode(assert, 'code'); - await toggleFileTree(); - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let instancePath = `${outerFolder}theme/theme-example.json`; - await openDir(assert, instancePath); - await verifyFileInFileTree(assert, instancePath); - let cardId = - testDestinationRealmURL + instancePath.replace('.json', ''); - await waitFor('[data-test-card-resource-loaded]'); - assert - .dom(`[data-test-code-mode-card-renderer-header="${cardId}"]`) - .exists(); - }); - }); - - skip('"use" is successful even if target realm does not have a trailing slash', async function (assert) { - const listingName = 'author'; - const listingId = mockCatalogURL + 'Listing/author.json'; - await executeCommand( - ListingUseCommand, - listingId, - removeTrailingSlash(testDestinationRealmURL), - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - - let instanceFolder = `${outerFolder}Author`; - await openDir(assert, instanceFolder); - await verifyJSONWithUUIDInFolder(assert, instanceFolder); - }); - - test('"install" is successful even if target realm does not have a trailing slash', async function (assert) { - const listingName = 'author'; - await executeCommand( - ListingInstallCommand, - authorListingId, - removeTrailingSlash(testDestinationRealmURL), - ); - await visitOperatorMode({ - submode: 'code', - fileView: 'browser', - codePath: `${testDestinationRealmURL}index`, - }); - - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - - let gtsFilePath = `${outerFolder}${listingName}/author.gts`; - await openDir(assert, gtsFilePath); - await verifyFileInFileTree(assert, gtsFilePath); - let instancePath = `${outerFolder}${listingName}/Author/example.json`; - - await openDir(assert, instancePath); - await verifyFileInFileTree(assert, instancePath); - }); - - test('"remix" is successful even if target realm does not have a trailing slash', async function (assert) { - const listingName = 'author'; - const listingId = `${mockCatalogURL}Listing/${listingName}`; - await visitOperatorMode({ - stacks: [[]], - }); - await executeCommand( - ListingRemixCommand, - listingId, - removeTrailingSlash(testDestinationRealmURL), - ); - await settled(); - await verifySubmode(assert, 'code'); - await toggleFileTree(); - let outerFolder = await verifyFolderWithUUIDInFileTree( - assert, - listingName, - ); - let instancePath = `${outerFolder}${listingName}/Author/example.json`; - await openDir(assert, instancePath); - await verifyFileInFileTree(assert, instancePath); - let gtsFilePath = `${outerFolder}${listingName}/author.gts`; - await openDir(assert, gtsFilePath); - await verifyFileInFileTree(assert, gtsFilePath); - await settled(); - assert - .dom( - '[data-test-playground-panel] [data-test-boxel-card-header-title]', - ) - .hasText('Author - Mike Dane'); - }); - }); - }, - ); -} - -function removeTrailingSlash(url: string): string { - if (url === undefined || url === null) { - throw new Error(`removeTrailingSlash called with invalid url: ${url}`); - } - return url.endsWith('/') && url.length > 1 ? url.slice(0, -1) : url; -} diff --git a/packages/catalog-realm/catalog-app-test-fixtures.ts b/packages/catalog-realm/catalog-app-test-fixtures.ts deleted file mode 100644 index 3d0a974e170..00000000000 --- a/packages/catalog-realm/catalog-app-test-fixtures.ts +++ /dev/null @@ -1,721 +0,0 @@ -export const authorCardSource = ` - import { field, contains, linksTo, CardDef } from 'https://cardstack.com/base/card-api'; - import StringField from 'https://cardstack.com/base/string'; - - - export class AuthorCompany extends CardDef { - static displayName = 'AuthorCompany'; - @field name = contains(StringField); - @field address = contains(StringField); - @field city = contains(StringField); - @field state = contains(StringField); - @field zip = contains(StringField); - } - - export class Author extends CardDef { - static displayName = 'Author'; - @field firstName = contains(StringField); - @field lastName = contains(StringField); - @field cardTitle = contains(StringField, { - computeVia: function (this: Author) { - return [this.firstName, this.lastName].filter(Boolean).join(' '); - }, - }); - @field company = linksTo(AuthorCompany); - } -`; - -export const blogPostCardSource = ` - import { field, contains, CardDef, linksTo } from 'https://cardstack.com/base/card-api'; - import StringField from 'https://cardstack.com/base/string'; - import { Author } from '../author/author'; - - export class BlogPost extends CardDef { - static displayName = 'BlogPost'; - @field cardTitle = contains(StringField); - @field content = contains(StringField); - @field author = linksTo(Author); - } -`; - -export const contactLinkFieldSource = ` - import { field, contains, FieldDef } from 'https://cardstack.com/base/card-api'; - import StringField from 'https://cardstack.com/base/string'; - - export class ContactLink extends FieldDef { - static displayName = 'ContactLink'; - @field label = contains(StringField); - @field url = contains(StringField); - @field type = contains(StringField); - } -`; - -export const appCardSource = ` - import { CardDef } from 'https://cardstack.com/base/card-api'; - - export class AppCard extends CardDef { - static displayName = 'App Card'; - static prefersWideFormat = true; - } -`; - -export const blogAppCardSource = ` - import { field, contains, containsMany } from 'https://cardstack.com/base/card-api'; - import StringField from 'https://cardstack.com/base/string'; - import { AppCard } from '../app-card'; - import { BlogPost } from '../blog-post/blog-post'; - - export class BlogApp extends AppCard { - static displayName = 'Blog App'; - @field cardTitle = contains(StringField); - @field posts = containsMany(BlogPost); - } -`; - -export const cardWithUnrecognisedImports = ` - import { field, CardDef, linksTo } from 'https://cardstack.com/base/card-api'; - // External import that should be ignored by sanitizeDeps - import { Chess as _ChessJS } from 'https://cdn.jsdelivr.net/npm/chess.js/+esm'; - import { Author } from './author/author'; - - export class UnrecognisedImports extends CardDef { - static displayName = 'Unrecognised Imports'; - @field author = linksTo(Author); - } -`; - -export function makeMockCatalogContents( - mockCatalogURL: string, - catalogRealmURL: string, -): Record { - const authorCompanyExampleId = `${mockCatalogURL}author/AuthorCompany/example`; - const authorSpecId = `${mockCatalogURL}Spec/author`; - const authorExampleId = `${mockCatalogURL}author/Author/example`; - const calculatorTagId = `${mockCatalogURL}Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7`; - const writingCategoryId = `${mockCatalogURL}Category/writing`; - const mitLicenseId = `${mockCatalogURL}License/mit`; - const publisherId = `${mockCatalogURL}Publisher/boxel-publisher`; - const pirateSkillId = `${mockCatalogURL}Skill/pirate-speak`; - const unknownSpecId = `${mockCatalogURL}Spec/unknown-no-type`; - const stubTagId = `${mockCatalogURL}Tag/stub`; - const authorListingId = `${mockCatalogURL}Listing/author`; - - return { - 'author/author.gts': authorCardSource, - 'blog-post/blog-post.gts': blogPostCardSource, - 'fields/contact-link.gts': contactLinkFieldSource, - 'app-card.gts': appCardSource, - 'blog-app/blog-app.gts': blogAppCardSource, - 'card-with-unrecognised-imports.gts': cardWithUnrecognisedImports, - 'theme/theme-example.json': { - data: { - type: 'card', - attributes: { - cssVariables: - ':root { --background: #ffffff; } .dark { --background: #000000; }', - cssImports: [], - cardInfo: { - cardTitle: 'Sample Theme', - cardDescription: 'A sample theme for testing remix.', - cardThumbnailURL: null, - notes: null, - }, - }, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/card-api', - name: 'Theme', - }, - }, - }, - }, - 'ThemeListing/cardstack-theme.json': { - data: { - meta: { - adoptsFrom: { - name: 'ThemeListing', - module: `${catalogRealmURL}catalog-app/listing/listing`, - }, - }, - type: 'card', - attributes: { - name: 'Cardstack Theme', - images: [], - summary: 'Cardstack base theme listing.', - }, - relationships: { - specs: { - links: { - self: null, - }, - }, - skills: { - links: { - self: null, - }, - }, - tags: { - links: { - self: null, - }, - }, - license: { - links: { - self: null, - }, - }, - publisher: { - links: { - self: null, - }, - }, - 'examples.0': { - links: { - self: '../theme/theme-example', - }, - }, - categories: { - links: { - self: null, - }, - }, - }, - }, - }, - 'author/Author/example.json': { - data: { - type: 'card', - attributes: { - firstName: 'Mike', - lastName: 'Dane', - summary: 'Author', - }, - relationships: { - company: { - links: { - self: authorCompanyExampleId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${mockCatalogURL}author/author`, - name: 'Author', - }, - }, - }, - }, - 'author/AuthorCompany/example.json': { - data: { - type: 'card', - attributes: { - name: 'Cardstack Labs', - address: '123 Main St', - city: 'Portland', - state: 'OR', - zip: '97205', - }, - meta: { - adoptsFrom: { - module: `${mockCatalogURL}author/author`, - name: 'AuthorCompany', - }, - }, - }, - }, - 'UnrecognisedImports/example.json': { - data: { - type: 'card', - attributes: {}, - meta: { - adoptsFrom: { - module: `${mockCatalogURL}card-with-unrecognised-imports`, - name: 'UnrecognisedImports', - }, - }, - }, - }, - 'blog-post/BlogPost/example.json': { - data: { - type: 'card', - attributes: { - cardTitle: 'Blog Post', - content: 'Blog Post Content', - }, - relationships: { - author: { - links: { - self: authorExampleId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${mockCatalogURL}blog-post/blog-post`, - name: 'BlogPost', - }, - }, - }, - }, - 'blog-app/BlogApp/example.json': { - data: { - type: 'card', - attributes: { - cardTitle: 'My Blog App', - }, - meta: { - adoptsFrom: { - module: `${mockCatalogURL}blog-app/blog-app`, - name: 'BlogApp', - }, - }, - }, - }, - 'Spec/author.json': { - data: { - type: 'card', - attributes: { - readMe: 'This is the author spec readme', - ref: { - name: 'Author', - module: `${mockCatalogURL}author/author`, - }, - }, - specType: 'card', - containedExamples: [], - cardTitle: 'Author', - cardDescription: 'Spec for Author card', - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/spec', - name: 'Spec', - }, - }, - }, - }, - 'Spec/contact-link.json': { - data: { - type: 'card', - attributes: { - ref: { - name: 'ContactLink', - module: `${mockCatalogURL}fields/contact-link`, - }, - }, - specType: 'field', - containedExamples: [], - cardTitle: 'ContactLink', - cardDescription: 'Spec for ContactLink field', - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/spec', - name: 'Spec', - }, - }, - }, - }, - 'Spec/unknown-no-type.json': { - data: { - type: 'card', - attributes: { - readMe: 'Spec without specType to trigger unknown grouping', - ref: { - name: 'UnknownNoType', - module: `${mockCatalogURL}unknown/unknown-no-type`, - }, - }, - // intentionally omitting specType so it falls into 'unknown' - containedExamples: [], - cardTitle: 'UnknownNoType', - cardDescription: 'Spec lacking specType', - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/spec', - name: 'Spec', - }, - }, - }, - }, - 'Listing/author.json': { - data: { - type: 'card', - attributes: { - name: 'Author', - cardTitle: 'Author', // hardcoding title otherwise test will be flaky when waiting for a computed - summary: 'A card for representing an author.', - }, - relationships: { - 'specs.0': { - links: { - self: authorSpecId, - }, - }, - 'examples.0': { - links: { - self: authorExampleId, - }, - }, - 'tags.0': { - links: { - self: calculatorTagId, - }, - }, - 'categories.0': { - links: { - self: writingCategoryId, - }, - }, - license: { - links: { - self: mitLicenseId, - }, - }, - publisher: { - links: { - self: publisherId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'CardListing', - }, - }, - }, - }, - 'Listing/blog-post.json': { - data: { - type: 'card', - attributes: { - name: 'Blog Post', - cardTitle: 'Blog Post', - }, - relationships: { - 'examples.0': { - links: { - self: `${mockCatalogURL}blog-post/BlogPost/example`, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'CardListing', - }, - }, - }, - }, - 'Publisher/boxel-publisher.json': { - data: { - type: 'card', - attributes: { - name: 'Boxel Publishing', - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/publisher`, - name: 'Publisher', - }, - }, - }, - }, - 'License/mit.json': { - data: { - type: 'card', - attributes: { - name: 'MIT License', - content: 'MIT License', - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/license`, - name: 'License', - }, - }, - }, - }, - 'Listing/person.json': { - data: { - type: 'card', - attributes: { - name: 'Person', - cardTitle: 'Person', // hardcoding title otherwise test will be flaky when waiting for a computed - images: [ - 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400', - 'https://images.unsplash.com/photo-1494790108755-2616b332db29?w=400', - 'https://images.unsplash.com/photo-1552374196-c4e7ffc6e126?w=400', - ], - }, - relationships: { - 'tags.0': { - links: { - self: calculatorTagId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'CardListing', - }, - }, - }, - }, - 'Listing/unknown-only.json': { - data: { - type: 'card', - attributes: {}, - relationships: { - 'specs.0': { - links: { - self: unknownSpecId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'CardListing', - }, - }, - }, - }, - 'AppListing/blog-app.json': { - data: { - type: 'card', - attributes: { - name: 'Blog App', - cardTitle: 'Blog App', // hardcoding title otherwise test will be flaky when waiting for a computed - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'AppListing', - }, - }, - }, - }, - 'Listing/empty.json': { - data: { - type: 'card', - attributes: { - name: 'Empty', - cardTitle: 'Empty', // hardcoding title otherwise test will be flaky when waiting for a computed - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'CardListing', - }, - }, - }, - }, - 'SkillListing/pirate-skill.json': { - data: { - type: 'card', - attributes: { - name: 'Pirate Skill', - cardTitle: 'Pirate Skill', // hardcoding title otherwise test will be flaky when waiting for a computed - }, - relationships: { - 'skills.0': { - links: { - self: pirateSkillId, - }, - }, - }, - 'categories.0': { - links: { - self: writingCategoryId, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'SkillListing', - }, - }, - }, - }, - 'Category/writing.json': { - data: { - type: 'card', - attributes: { - name: 'Writing', - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/category`, - name: 'Category', - }, - }, - }, - }, - 'Listing/incomplete-skill.json': { - data: { - type: 'card', - attributes: { - name: 'Incomplete Skill', - cardTitle: 'Incomplete Skill', // hardcoding title otherwise test will be flaky when waiting for a computed - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'SkillListing', - }, - }, - }, - }, - 'Skill/pirate-speak.json': { - data: { - type: 'card', - attributes: { - cardTitle: 'Talk Like a Pirate', - name: 'Pirate Speak', - }, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/skill', - name: 'Skill', - }, - }, - }, - }, - 'Tag/c1fe433a-b3df-41f4-bdcf-d98686ee42d7.json': { - data: { - type: 'card', - attributes: { - name: 'Calculator', - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/tag`, - name: 'Tag', - }, - }, - }, - }, - 'Tag/51de249c-516a-4c4d-bd88-76e88274c483.json': { - data: { - type: 'card', - attributes: { - name: 'Game', - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/tag`, - name: 'Tag', - }, - }, - }, - }, - 'Tag/stub.json': { - data: { - type: 'card', - attributes: { - name: 'Stub', - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/tag`, - name: 'Tag', - }, - }, - }, - }, - 'Listing/api-documentation-stub.json': { - data: { - type: 'card', - attributes: { - name: 'API Documentation', - cardTitle: 'API Documentation', // hardcoding title otherwise test will be flaky when waiting for a computed - }, - relationships: { - 'tags.0': { - links: { - self: stubTagId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'Listing', - }, - }, - }, - }, - 'FieldListing/contact-link.json': { - data: { - type: 'card', - attributes: { - name: 'Contact Link', - cardTitle: 'Contact Link', // hardcoding title otherwise test will be flaky when waiting for a computed - summary: - 'A field for creating and managing contact links such as email, phone, or other web links.', - }, - relationships: { - 'specs.0': { - links: { - self: `${mockCatalogURL}Spec/contact-link`, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/listing/listing`, - name: 'FieldListing', - }, - }, - }, - }, - 'index.json': { - data: { - type: 'card', - attributes: {}, - relationships: { - 'startHere.0': { - links: { - self: authorListingId, - }, - }, - }, - meta: { - adoptsFrom: { - module: `${catalogRealmURL}catalog-app/catalog`, - name: 'Catalog', - }, - }, - }, - }, - '.realm.json': { - name: 'Cardstack Catalog', - backgroundURL: - 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', - iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', - }, - }; -} - -export function makeDestinationRealmContents(): Record { - return { - 'index.json': { - data: { - type: 'card', - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/cards-grid', - name: 'CardsGrid', - }, - }, - }, - }, - '.realm.json': { - name: 'Test Workspace B', - backgroundURL: - 'https://i.postimg.cc/VNvHH93M/pawel-czerwinski-Ly-ZLa-A5jti-Y-unsplash.jpg', - iconURL: 'https://i.postimg.cc/L8yXRvws/icon.png', - }, - }; -} From 9ad716e81ae4195707f2253a3272a92763379c8d Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 17 Apr 2026 14:07:35 +0800 Subject: [PATCH 4/7] normalize fetches to account for @cardstack/catalog --- packages/host/app/commands/fetch-card-json.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/host/app/commands/fetch-card-json.ts b/packages/host/app/commands/fetch-card-json.ts index 0742b36cb02..61b5437a345 100644 --- a/packages/host/app/commands/fetch-card-json.ts +++ b/packages/host/app/commands/fetch-card-json.ts @@ -1,5 +1,7 @@ import { service } from '@ember/service'; +import { cardIdToURL } from '@cardstack/runtime-common'; + import type * as BaseCommandModule from 'https://cardstack.com/base/command'; import HostBaseCommand from '../lib/host-base-command'; @@ -27,7 +29,7 @@ export default class FetchCardJsonCommand extends HostBaseCommand< ): Promise { let commandModule = await this.loadCommandModule(); const { FetchCardJsonResult } = commandModule; - const doc = await this.cardService.fetchJSON(input.url); + const doc = await this.cardService.fetchJSON(cardIdToURL(input.url)); return new FetchCardJsonResult({ document: doc }); } } From a64e2341f92a069720a6373d9d40b387b34ddde7 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 17 Apr 2026 14:11:13 +0800 Subject: [PATCH 5/7] fix lint --- packages/host/app/commands/index.ts | 10 +++++----- packages/host/app/commands/listing-create.ts | 2 +- packages/host/app/commands/listing-install.ts | 3 ++- packages/host/app/commands/listing-remix.ts | 15 +++++++-------- .../host/app/commands/sanitize-module-list.ts | 4 +--- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/host/app/commands/index.ts b/packages/host/app/commands/index.ts index 2da17cd96e2..a233151e24c 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -5,9 +5,9 @@ import * as UseAiAssistantCommandModule from './ai-assistant'; import * as ApplyMarkdownEditCommandModule from './apply-markdown-edit'; import * as ApplySearchReplaceBlockCommandModule from './apply-search-replace-block'; import * as AskAiCommandModule from './ask-ai'; +import * as AuthedFetchCommandModule from './authed-fetch'; import * as CreateListingPRRequestCommandModule from './bot-requests/create-listing-pr-request'; import * as SendBotTriggerEventCommandModule from './bot-requests/send-bot-trigger-event'; -import * as AuthedFetchCommandModule from './authed-fetch'; import * as CanReadRealmCommandModule from './can-read-realm'; import * as CancelIndexingJobCommandModule from './cancel-indexing-job'; import * as CheckCorrectnessCommandModule from './check-correctness'; @@ -20,8 +20,8 @@ import * as CreateAIAssistantRoomCommandModule from './create-ai-assistant-room' import * as CreateAndOpenSubmissionWorkflowCard from './create-and-open-submission-workflow-card'; import * as CreateSpecCommandModule from './create-specs'; import * as CreateSubmissionWorkflowCommandModule from './create-submission-workflow'; -import * as ExecuteAtomicOperationsCommandModule from './execute-atomic-operations'; import * as EvaluateModuleCommandModule from './evaluate-module'; +import * as ExecuteAtomicOperationsCommandModule from './execute-atomic-operations'; import * as FetchCardJsonCommandModule from './fetch-card-json'; import * as FullReindexRealmCommandModule from './full-reindex-realm'; import * as GenerateExampleCardsCommandModule from './generate-example-cards'; @@ -30,8 +30,8 @@ import * as GenerateThemeExampleCommandModule from './generate-theme-example'; import * as GetAllRealmMetasCommandModule from './get-all-realm-metas'; import * as GetAvailableRealmUrlsCommandModule from './get-available-realm-urls'; import * as GetCardCommandModule from './get-card'; -import * as GetCatalogRealmUrlsCommandModule from './get-catalog-realm-urls'; import * as GetCardTypeSchemaCommandModule from './get-card-type-schema'; +import * as GetCatalogRealmUrlsCommandModule from './get-catalog-realm-urls'; import * as GetDefaultWritableRealmCommandModule from './get-default-writable-realm'; import * as GetEventsFromRoomCommandModule from './get-events-from-room'; import * as GetRealmOfUrlCommandModule from './get-realm-of-url'; @@ -68,7 +68,6 @@ import * as RegisterBotCommandModule from './register-bot'; import * as ReindexRealmCommandModule from './reindex-realm'; import * as SanitizeModuleListCommandModule from './sanitize-module-list'; import * as SaveCardCommandModule from './save-card'; -import * as StoreAddCommandModule from './store-add'; import * as SearchAndChooseCommandModule from './search-and-choose'; import * as SearchCardsCommandModule from './search-cards'; import * as SearchGoogleImagesCommandModule from './search-google-images'; @@ -79,6 +78,7 @@ import * as SetActiveLlmModule from './set-active-llm'; import * as SetUserSystemCardCommandModule from './set-user-system-card'; import * as ShowCardCommandModule from './show-card'; import * as ShowFileCommandModule from './show-file'; +import * as StoreAddCommandModule from './store-add'; import * as SummarizeSessionCommandModule from './summarize-session'; import * as SwitchSubmodeCommandModule from './switch-submode'; import * as SyncOpenRouterModelsCommandModule from './sync-openrouter-models'; @@ -87,8 +87,8 @@ import * as UnregisterBotCommandModule from './unregister-bot'; import * as UpdateCodePathWithSelectionCommandModule from './update-code-path-with-selection'; import * as UpdatePlaygroundSelectionCommandModule from './update-playground-selection'; import * as UpdateRoomSkillsCommandModule from './update-room-skills'; -import * as ValidateRealmCommandModule from './validate-realm'; import * as CommandUtilsModule from './utils'; +import * as ValidateRealmCommandModule from './validate-realm'; import * as WriteTextFileCommandModule from './write-text-file'; import type HostBaseCommand from '../lib/host-base-command'; diff --git a/packages/host/app/commands/listing-create.ts b/packages/host/app/commands/listing-create.ts index 63239ad09ba..2a860c1ebba 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -24,8 +24,8 @@ import HostBaseCommand from '../lib/host-base-command'; import AuthedFetchCommand from './authed-fetch'; import CreateSpecCommand from './create-specs'; -import GetCatalogRealmUrlsCommand from './get-catalog-realm-urls'; import GetCardCommand from './get-card'; +import GetCatalogRealmUrlsCommand from './get-catalog-realm-urls'; import GetRealmOfUrlCommand from './get-realm-of-url'; import OneShotLlmRequestCommand from './one-shot-llm-request'; import SanitizeModuleListCommand from './sanitize-module-list'; diff --git a/packages/host/app/commands/listing-install.ts b/packages/host/app/commands/listing-install.ts index 55d8718214d..150db4e7802 100644 --- a/packages/host/app/commands/listing-install.ts +++ b/packages/host/app/commands/listing-install.ts @@ -119,7 +119,8 @@ export default class ListingInstallCommand extends HostBaseCommand< } delete (doc as any).data.id; delete (doc as any).included; - let cardResource: LooseCardResource = (doc as any).data as LooseCardResource; + let cardResource: LooseCardResource = (doc as any) + .data as LooseCardResource; let href = join(realmUrl, copyInstanceMeta.lid) + '.json'; return { op: 'add' as const, href, data: cardResource }; }), diff --git a/packages/host/app/commands/listing-remix.ts b/packages/host/app/commands/listing-remix.ts index b05d27d7e0e..8ac4db48403 100644 --- a/packages/host/app/commands/listing-remix.ts +++ b/packages/host/app/commands/listing-remix.ts @@ -12,18 +12,17 @@ import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; import UseAiAssistantCommand from './ai-assistant'; import ListingInstallCommand from './listing-install'; -import ValidateRealmCommand from './validate-realm'; import PersistModuleInspectorViewCommand from './persist-module-inspector-view'; import SwitchSubmodeCommand from './switch-submode'; import UpdateCodePathWithSelectionCommand from './update-code-path-with-selection'; import UpdatePlaygroundSelectionCommand from './update-playground-selection'; +import ValidateRealmCommand from './validate-realm'; import type { Listing } from '@cardstack/catalog/catalog-app/listing/listing'; export default class RemixCommand extends HostBaseCommand< typeof BaseCommandModule.ListingInstallInput > { - static actionVerb = 'Remix'; description = @@ -75,12 +74,12 @@ export default class RemixCommand extends HostBaseCommand< }, ); - await new PersistModuleInspectorViewCommand(this.commandContext).execute( - { - codePath: codePath + '.gts', - moduleInspectorView: 'preview', - }, - ); + await new PersistModuleInspectorViewCommand( + this.commandContext, + ).execute({ + codePath: codePath + '.gts', + moduleInspectorView: 'preview', + }); } await new UpdateCodePathWithSelectionCommand(this.commandContext).execute( diff --git a/packages/host/app/commands/sanitize-module-list.ts b/packages/host/app/commands/sanitize-module-list.ts index e3d96b7ace6..7ddb63ec8b8 100644 --- a/packages/host/app/commands/sanitize-module-list.ts +++ b/packages/host/app/commands/sanitize-module-list.ts @@ -69,9 +69,7 @@ export default class SanitizeModuleListCommand extends HostBaseCommand< }), ); - const moduleUrls = results.filter( - (dep): dep is string => dep !== null, - ); + const moduleUrls = results.filter((dep): dep is string => dep !== null); let commandModule = await this.loadCommandModule(); const { SanitizeModuleListResult } = commandModule; From 1fb5e5b256cb445b8b5decb75e53f464604f271d Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 17 Apr 2026 14:11:38 +0800 Subject: [PATCH 6/7] can we possibly remove the boxel-icons urls tht was added pls --- packages/host/app/lib/externals.ts | 50 ++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/host/app/lib/externals.ts b/packages/host/app/lib/externals.ts index 101f1da7362..93875b79053 100644 --- a/packages/host/app/lib/externals.ts +++ b/packages/host/app/lib/externals.ts @@ -14,6 +14,16 @@ import * as emberTestHelpers from '@ember/test-helpers'; import * as glimmerComponent from '@glimmer/component'; import * as glimmerTracking from '@glimmer/tracking'; +import * as boxelIconBuilding from '@cardstack/boxel-icons/building'; +import * as boxelIconBuildingBank from '@cardstack/boxel-icons/building-bank'; +import * as boxelIconCaptions from '@cardstack/boxel-icons/captions'; +import * as boxelIconCategory from '@cardstack/boxel-icons/category'; +import * as boxelIconHealthRecognition from '@cardstack/boxel-icons/health-recognition'; +import * as boxelIconImage from '@cardstack/boxel-icons/image'; +import * as boxelIconLayoutGridPlus from '@cardstack/boxel-icons/layout-grid-plus'; +import * as boxelIconPackage from '@cardstack/boxel-icons/package'; +import * as boxelIconRefresh from '@cardstack/boxel-icons/refresh'; +import * as boxelIconTag from '@cardstack/boxel-icons/tag'; import * as viewTransitions from '@cardstack/view-transitions'; import * as floatingUiDom from '@floating-ui/dom'; import * as awesomePhoneNumber from 'awesome-phonenumber'; @@ -58,16 +68,6 @@ import * as boxelUiModifiers from '@cardstack/boxel-ui/modifiers'; // are rendered during live tests. The @cardstack/boxel-icons package exports // each icon as a separate module — unlike @cardstack/boxel-ui/icons (an // aggregate), these must be shimmed individually. -import * as boxelIconBuildingBank from '@cardstack/boxel-icons/building-bank'; -import * as boxelIconBuilding from '@cardstack/boxel-icons/building'; -import * as boxelIconCaptions from '@cardstack/boxel-icons/captions'; -import * as boxelIconCategory from '@cardstack/boxel-icons/category'; -import * as boxelIconHealthRecognition from '@cardstack/boxel-icons/health-recognition'; -import * as boxelIconImage from '@cardstack/boxel-icons/image'; -import * as boxelIconLayoutGridPlus from '@cardstack/boxel-icons/layout-grid-plus'; -import * as boxelIconPackage from '@cardstack/boxel-icons/package'; -import * as boxelIconRefresh from '@cardstack/boxel-icons/refresh'; -import * as boxelIconTag from '@cardstack/boxel-icons/tag'; import * as boxelIconUsers from '@cardstack/boxel-icons/users'; import * as boxelIconWand from '@cardstack/boxel-icons/wand'; import * as boxelIconWorld from '@cardstack/boxel-icons/world'; @@ -98,13 +98,31 @@ export function shimExternals(virtualNetwork: VirtualNetwork) { virtualNetwork.shimModule('@cardstack/boxel-ui/helpers', boxelUiHelpers); virtualNetwork.shimModule('@cardstack/boxel-ui/icons', boxelUiIcons); virtualNetwork.shimModule('@cardstack/boxel-ui/modifiers', boxelUiModifiers); - virtualNetwork.shimModule('@cardstack/boxel-icons/building-bank', boxelIconBuildingBank); - virtualNetwork.shimModule('@cardstack/boxel-icons/building', boxelIconBuilding); - virtualNetwork.shimModule('@cardstack/boxel-icons/captions', boxelIconCaptions); - virtualNetwork.shimModule('@cardstack/boxel-icons/category', boxelIconCategory); - virtualNetwork.shimModule('@cardstack/boxel-icons/health-recognition', boxelIconHealthRecognition); + virtualNetwork.shimModule( + '@cardstack/boxel-icons/building-bank', + boxelIconBuildingBank, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-icons/building', + boxelIconBuilding, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-icons/captions', + boxelIconCaptions, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-icons/category', + boxelIconCategory, + ); + virtualNetwork.shimModule( + '@cardstack/boxel-icons/health-recognition', + boxelIconHealthRecognition, + ); virtualNetwork.shimModule('@cardstack/boxel-icons/image', boxelIconImage); - virtualNetwork.shimModule('@cardstack/boxel-icons/layout-grid-plus', boxelIconLayoutGridPlus); + virtualNetwork.shimModule( + '@cardstack/boxel-icons/layout-grid-plus', + boxelIconLayoutGridPlus, + ); virtualNetwork.shimModule('@cardstack/boxel-icons/package', boxelIconPackage); virtualNetwork.shimModule('@cardstack/boxel-icons/refresh', boxelIconRefresh); virtualNetwork.shimModule('@cardstack/boxel-icons/tag', boxelIconTag); From f1323e4057dde23f79a751eafba8ba241a516b50 Mon Sep 17 00:00:00 2001 From: tintinthong Date: Fri, 17 Apr 2026 14:58:45 +0800 Subject: [PATCH 7/7] remove all boxel-icons --- packages/host/app/lib/externals.ts | 49 ------------------------------ 1 file changed, 49 deletions(-) diff --git a/packages/host/app/lib/externals.ts b/packages/host/app/lib/externals.ts index 93875b79053..b27222e05c6 100644 --- a/packages/host/app/lib/externals.ts +++ b/packages/host/app/lib/externals.ts @@ -14,16 +14,6 @@ import * as emberTestHelpers from '@ember/test-helpers'; import * as glimmerComponent from '@glimmer/component'; import * as glimmerTracking from '@glimmer/tracking'; -import * as boxelIconBuilding from '@cardstack/boxel-icons/building'; -import * as boxelIconBuildingBank from '@cardstack/boxel-icons/building-bank'; -import * as boxelIconCaptions from '@cardstack/boxel-icons/captions'; -import * as boxelIconCategory from '@cardstack/boxel-icons/category'; -import * as boxelIconHealthRecognition from '@cardstack/boxel-icons/health-recognition'; -import * as boxelIconImage from '@cardstack/boxel-icons/image'; -import * as boxelIconLayoutGridPlus from '@cardstack/boxel-icons/layout-grid-plus'; -import * as boxelIconPackage from '@cardstack/boxel-icons/package'; -import * as boxelIconRefresh from '@cardstack/boxel-icons/refresh'; -import * as boxelIconTag from '@cardstack/boxel-icons/tag'; import * as viewTransitions from '@cardstack/view-transitions'; import * as floatingUiDom from '@floating-ui/dom'; import * as awesomePhoneNumber from 'awesome-phonenumber'; @@ -64,14 +54,6 @@ import * as boxelUiHelpers from '@cardstack/boxel-ui/helpers'; import * as boxelUiIcons from '@cardstack/boxel-ui/icons'; import * as boxelUiModifiers from '@cardstack/boxel-ui/modifiers'; -// Individual @cardstack/boxel-icons used by catalog-realm card modules that -// are rendered during live tests. The @cardstack/boxel-icons package exports -// each icon as a separate module — unlike @cardstack/boxel-ui/icons (an -// aggregate), these must be shimmed individually. -import * as boxelIconUsers from '@cardstack/boxel-icons/users'; -import * as boxelIconWand from '@cardstack/boxel-icons/wand'; -import * as boxelIconWorld from '@cardstack/boxel-icons/world'; - import * as runtime from '@cardstack/runtime-common'; import type { VirtualNetwork } from '@cardstack/runtime-common'; @@ -98,37 +80,6 @@ export function shimExternals(virtualNetwork: VirtualNetwork) { virtualNetwork.shimModule('@cardstack/boxel-ui/helpers', boxelUiHelpers); virtualNetwork.shimModule('@cardstack/boxel-ui/icons', boxelUiIcons); virtualNetwork.shimModule('@cardstack/boxel-ui/modifiers', boxelUiModifiers); - virtualNetwork.shimModule( - '@cardstack/boxel-icons/building-bank', - boxelIconBuildingBank, - ); - virtualNetwork.shimModule( - '@cardstack/boxel-icons/building', - boxelIconBuilding, - ); - virtualNetwork.shimModule( - '@cardstack/boxel-icons/captions', - boxelIconCaptions, - ); - virtualNetwork.shimModule( - '@cardstack/boxel-icons/category', - boxelIconCategory, - ); - virtualNetwork.shimModule( - '@cardstack/boxel-icons/health-recognition', - boxelIconHealthRecognition, - ); - virtualNetwork.shimModule('@cardstack/boxel-icons/image', boxelIconImage); - virtualNetwork.shimModule( - '@cardstack/boxel-icons/layout-grid-plus', - boxelIconLayoutGridPlus, - ); - virtualNetwork.shimModule('@cardstack/boxel-icons/package', boxelIconPackage); - virtualNetwork.shimModule('@cardstack/boxel-icons/refresh', boxelIconRefresh); - virtualNetwork.shimModule('@cardstack/boxel-icons/tag', boxelIconTag); - virtualNetwork.shimModule('@cardstack/boxel-icons/users', boxelIconUsers); - virtualNetwork.shimModule('@cardstack/boxel-icons/wand', boxelIconWand); - virtualNetwork.shimModule('@cardstack/boxel-icons/world', boxelIconWorld); virtualNetwork.shimModule('@glimmer/component', glimmerComponent); virtualNetwork.shimModule('@glimmer/tracking', glimmerTracking); virtualNetwork.shimModule('@ember/component', emberComponent);