diff --git a/packages/base/command.gts b/packages/base/command.gts index e20e395f1a..62da98d041 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); } @@ -534,6 +539,84 @@ 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 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 { @field query = contains(StringField); @field maxResults = contains(NumberField); // optional, default 10 diff --git a/packages/host/app/commands/authed-fetch.ts b/packages/host/app/commands/authed-fetch.ts new file mode 100644 index 0000000000..eefa68502b --- /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 0000000000..eb5bb7b209 --- /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 0000000000..fbc3ccbbd9 --- /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 0000000000..61b5437a34 --- /dev/null +++ b/packages/host/app/commands/fetch-card-json.ts @@ -0,0 +1,35 @@ +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'; + +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(cardIdToURL(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 0000000000..8ea44b326a --- /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 0000000000..1816f4121e --- /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 0000000000..da07f630c3 --- /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({ + realmUrl: 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 0000000000..b5a1dc453b --- /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 404700a85f..a233151e24 100644 --- a/packages/host/app/commands/index.ts +++ b/packages/host/app/commands/index.ts @@ -5,8 +5,10 @@ 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 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'; @@ -19,14 +21,20 @@ import * as CreateAndOpenSubmissionWorkflowCard from './create-and-open-submissi import * as CreateSpecCommandModule from './create-specs'; import * as CreateSubmissionWorkflowCommandModule from './create-submission-workflow'; 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'; 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 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'; import * as GetUserSystemCardCommandModule from './get-user-system-card'; import * as InstantiateCardCommandModule from './instantiate-card'; import * as InvalidateRealmUrlsCommandModule from './invalidate-realm-urls'; @@ -49,6 +57,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'; @@ -57,6 +66,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 SearchAndChooseCommandModule from './search-and-choose'; import * as SearchCardsCommandModule from './search-cards'; @@ -68,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'; @@ -77,6 +88,7 @@ import * as UpdateCodePathWithSelectionCommandModule from './update-code-path-wi import * as UpdatePlaygroundSelectionCommandModule from './update-playground-selection'; import * as UpdateRoomSkillsCommandModule from './update-room-skills'; 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'; @@ -90,6 +102,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, @@ -146,6 +162,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, @@ -222,6 +246,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, @@ -378,6 +406,38 @@ 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/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, @@ -410,14 +470,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, @@ -444,6 +512,7 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ PatchCodeCommandModule.default, PatchFieldsCommandModule.default, PatchThemeCommandModule.default, + PersistModuleInspectorViewCommandModule.default, PopulateWithSampleDataCommandModule.default, PreviewFormatCommandModule.default, ReadCardForAiAssistantCommandModule.default, @@ -453,6 +522,8 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ RegisterBotCommandModule.default, ReindexRealmCommandModule.default, SaveCardCommandModule.default, + SanitizeModuleListCommandModule.default, + StoreAddCommandModule.default, SerializeCardCommandModule.default, SearchAndChooseCommandModule.default, SearchCardsCommandModule.SearchCardsByQueryCommand, @@ -478,5 +549,6 @@ export const HostCommandClasses: (typeof HostBaseCommand)[] = [ UpdatePlaygroundSelectionCommandModule.default, UpdateRoomSkillsCommandModule.default, UseAiAssistantCommandModule.default, + ValidateRealmCommandModule.default, WriteTextFileCommandModule.default, ]; diff --git a/packages/host/app/commands/listing-action-build.ts b/packages/host/app/commands/listing-action-build.ts index bed17b6e9a..9eb0d75a4c 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 fb8a6f1143..ec57c7df7f 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 d08771639f..2a860c1ebb 100644 --- a/packages/host/app/commands/listing-create.ts +++ b/packages/host/app/commands/listing-create.ts @@ -1,7 +1,3 @@ -import { service } from '@ember/service'; - -import { isScopedCSSRequest } from 'glimmer-scoped-css'; - import type { LooseSingleCardDocument, ResolvedCodeRef, @@ -26,15 +22,16 @@ import type { Spec } from 'https://cardstack.com/base/spec'; import HostBaseCommand from '../lib/host-base-command'; +import AuthedFetchCommand from './authed-fetch'; import CreateSpecCommand from './create-specs'; +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'; 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 +49,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,41 +67,13 @@ export default class ListingCreateCommand extends HostBaseCommand< requireInputFields = ['codeRef', 'targetRealm']; - private sanitizeModuleList(modulesToCreate: Iterable) { - // 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()); - 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); - }); + private async sanitizeModuleList( + modulesToCreate: Iterable, + ): Promise { + const { moduleUrls } = await new SanitizeModuleListCommand( + this.commandContext, + ).execute({ moduleUrls: Array.from(modulesToCreate) }); + return moduleUrls; } protected async run( @@ -127,6 +92,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 +107,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 +198,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 +367,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 +452,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 +466,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 +492,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 2e03a13c63..93a2e03595 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 { realmUrl: 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 907421bb5c..150db4e780 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, @@ -7,21 +5,14 @@ import type { } from '@cardstack/runtime-common'; import { type ResolvedCodeRef, - RealmPaths, join, planModuleInstall, 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 +21,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 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'; const log = logger('catalog:install'); @@ -41,10 +36,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,14 +50,11 @@ 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; - - if (!realmUrls.includes(realmUrl)) { - throw new Error(`Invalid realm: ${realmUrl}`); - } + let { realmUrl } = await new ValidateRealmCommand( + this.commandContext, + ).execute({ realmUrl: realm }); // this is intentionally to type because base command cannot interpret Listing type from catalog const listing = listingInput as Listing; @@ -107,62 +95,46 @@ 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 +165,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 c8a15eb9db..8ac4db4840 100644 --- a/packages/host/app/commands/listing-remix.ts +++ b/packages/host/app/commands/listing-remix.ts @@ -1,8 +1,5 @@ -import { service } from '@ember/service'; - import { isResolvedCodeRef, - RealmPaths, type ResolvedCodeRef, } from '@cardstack/runtime-common'; import { DEFAULT_CODING_LLM } from '@cardstack/runtime-common/matrix-constants'; @@ -15,20 +12,17 @@ import { skillCardURL, devSkillId, envSkillId } from '../lib/utils'; import UseAiAssistantCommand from './ai-assistant'; 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 ValidateRealmCommand from './validate-realm'; -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'; description = @@ -80,10 +74,12 @@ export default class RemixCommand extends HostBaseCommand< }, ); - this.operatorModeStateService.persistModuleInspectorView( - codePath + '.gts', - 'preview', - ); + await new PersistModuleInspectorViewCommand( + this.commandContext, + ).execute({ + codePath: codePath + '.gts', + moduleInspectorView: 'preview', + }); } await new UpdateCodePathWithSelectionCommand(this.commandContext).execute( @@ -112,14 +108,10 @@ 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 - if (!realmUrls.includes(realmUrl)) { - throw new Error(`Invalid realm: ${realmUrl}`); - } + let { realmUrl } = await new ValidateRealmCommand( + this.commandContext, + ).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 7e284171a7..b4dd3ff0a5 100644 --- a/packages/host/app/commands/listing-update-specs.ts +++ b/packages/host/app/commands/listing-update-specs.ts @@ -1,7 +1,3 @@ -import { service } from '@ember/service'; - -import { isScopedCSSRequest } from 'glimmer-scoped-css'; - import { isCardInstance, SupportedMimeType } from '@cardstack/runtime-common'; import { realmURL as realmURLSymbol } from '@cardstack/runtime-common'; @@ -11,18 +7,14 @@ import type { Spec } from 'https://cardstack.com/base/spec'; import HostBaseCommand from '../lib/host-base-command'; +import AuthedFetchCommand from './authed-fetch'; import CreateSpecCommand from './create-specs'; - -import type NetworkService from '../services/network'; -import type RealmService from '../services/realm'; +import SanitizeModuleListCommand from './sanitize-module-list'; 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 +25,11 @@ 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; - } - return this.realm.canRead(realmURL.href); - } catch { - return false; - } - }); + private async sanitizeDeps(deps: string[]): Promise { + const { moduleUrls } = await new SanitizeModuleListCommand( + this.commandContext, + ).execute({ moduleUrls: deps }); + return moduleUrls; } protected async run( @@ -82,28 +54,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 +82,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 8ed65b72c4..9fa2ef2af8 100644 --- a/packages/host/app/commands/listing-use.ts +++ b/packages/host/app/commands/listing-use.ts @@ -1,12 +1,9 @@ -import { service } from '@ember/service'; - import { cardIdToURL, codeRefWithAbsoluteURL, isResolvedCodeRef, loadCardDef, generateInstallFolderName, - RealmPaths, } from '@cardstack/runtime-common'; import type * as CardAPI from 'https://cardstack.com/base/card-api'; @@ -18,15 +15,13 @@ import HostBaseCommand from '../lib/host-base-command'; import CopyCardToRealmCommand from './copy-card'; import SaveCardCommand from './save-card'; +import ValidateRealmCommand from './validate-realm'; -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,17 +35,13 @@ 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; - let realmUrl = new RealmPaths(new URL(realm)).url; - - // Make sure realm is valid - if (!realmUrls.includes(realmUrl)) { - throw new Error(`Invalid realm: ${realmUrl}`); - } + let { realmUrl } = await new ValidateRealmCommand( + this.commandContext, + ).execute({ realmUrl: realm }); const specsToCopy = listing.specs ?? []; const specsWithoutFields = specsToCopy.filter( 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 0000000000..25e67c43c0 --- /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/sanitize-module-list.ts b/packages/host/app/commands/sanitize-module-list.ts new file mode 100644 index 0000000000..7ddb63ec8b --- /dev/null +++ b/packages/host/app/commands/sanitize-module-list.ts @@ -0,0 +1,78 @@ +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/store-add.ts b/packages/host/app/commands/store-add.ts new file mode 100644 index 0000000000..0688abc80e --- /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/commands/validate-realm.ts b/packages/host/app/commands/validate-realm.ts new file mode 100644 index 0000000000..7887e2c2af --- /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 }); + } +}