From 36f9a37d3f0851406a1db9ef67ca00f7639499ee Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 7 Apr 2026 22:47:06 +0800 Subject: [PATCH 1/6] Fix "Invalid base URL" when creating card def inheriting from cross-realm specs --- .../components/operator-mode/create-file-modal.gts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/host/app/components/operator-mode/create-file-modal.gts b/packages/host/app/components/operator-mode/create-file-modal.gts index 0c9887a5d83..ab08029803f 100644 --- a/packages/host/app/components/operator-mode/create-file-modal.gts +++ b/packages/host/app/components/operator-mode/create-file-modal.gts @@ -47,6 +47,7 @@ import { type ResolvedCodeRef, type CardErrorJSONAPI, } from '@cardstack/runtime-common'; +import { cardIdToURL } from '@cardstack/runtime-common/card-reference-resolver'; import { codeRefWithAbsoluteURL } from '@cardstack/runtime-common/code-ref'; import CopyCardToRealmCommand from '@cardstack/host/commands/copy-card'; @@ -826,7 +827,12 @@ export default class CreateFileModal extends Component { ref: { name: exportName, module }, } = (this.definitionClass ?? spec)!; // we just checked above to make sure one of these exists let className = convertToClassName(this.displayName); - let absoluteModule = new URL(module, spec?.id); + // Use spec.moduleHref which correctly resolves relative module paths + // via resolveCardReference (handles @cardstack/catalog/... prefix IDs). + // Without this, new URL(relativeModule, prefixId) throws "Invalid base URL". + let absoluteModule = spec?.moduleHref + ? new URL(spec.moduleHref) + : new URL(module); let moduleURL = maybeRelativeURL( absoluteModule, url, @@ -929,9 +935,7 @@ export class ${className} extends ${exportName} { let { ref } = (this.definitionClass ? this.definitionClass : spec)!; // we just checked above to make sure one of these exist - let relativeTo = spec - ? new URL(spec.id!) // only new cards are missing urls - : undefined; + let relativeTo = spec?.id ? cardIdToURL(spec.id) : undefined; // we make the code ref use an absolute URL for safety in // the case it's being created in a different realm than where the card // definition comes from. The server will make relative URL if appropriate after creation From 749ea654c686ebfff44281889e57147075fae87c Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 8 Apr 2026 12:06:27 +0800 Subject: [PATCH 2/6] fix lint --- .../components/operator-mode/create-file-modal.gts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/host/app/components/operator-mode/create-file-modal.gts b/packages/host/app/components/operator-mode/create-file-modal.gts index ab08029803f..5ca7a587cb3 100644 --- a/packages/host/app/components/operator-mode/create-file-modal.gts +++ b/packages/host/app/components/operator-mode/create-file-modal.gts @@ -32,6 +32,7 @@ import { } from '@cardstack/boxel-ui/icons'; import { + cardIdToURL, specRef, chooseCard, baseRealm, @@ -47,7 +48,6 @@ import { type ResolvedCodeRef, type CardErrorJSONAPI, } from '@cardstack/runtime-common'; -import { cardIdToURL } from '@cardstack/runtime-common/card-reference-resolver'; import { codeRefWithAbsoluteURL } from '@cardstack/runtime-common/code-ref'; import CopyCardToRealmCommand from '@cardstack/host/commands/copy-card'; @@ -827,12 +827,14 @@ export default class CreateFileModal extends Component { ref: { name: exportName, module }, } = (this.definitionClass ?? spec)!; // we just checked above to make sure one of these exists let className = convertToClassName(this.displayName); - // Use spec.moduleHref which correctly resolves relative module paths - // via resolveCardReference (handles @cardstack/catalog/... prefix IDs). - // Without this, new URL(relativeModule, prefixId) throws "Invalid base URL". let absoluteModule = spec?.moduleHref ? new URL(spec.moduleHref) - : new URL(module); + : new URL( + codeRefWithAbsoluteURL( + { module, name: exportName }, + new URL(this.selectedRealmURL), + ).module, + ); let moduleURL = maybeRelativeURL( absoluteModule, url, From 6a407d2f1cc56be5a5b42fe5d64375d711558676 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 8 Apr 2026 21:40:40 +0800 Subject: [PATCH 3/6] fix lint --- .../operator-mode/create-file-modal.gts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/host/app/components/operator-mode/create-file-modal.gts b/packages/host/app/components/operator-mode/create-file-modal.gts index 5ca7a587cb3..7ca6ab85d42 100644 --- a/packages/host/app/components/operator-mode/create-file-modal.gts +++ b/packages/host/app/components/operator-mode/create-file-modal.gts @@ -827,14 +827,16 @@ export default class CreateFileModal extends Component { ref: { name: exportName, module }, } = (this.definitionClass ?? spec)!; // we just checked above to make sure one of these exists let className = convertToClassName(this.displayName); - let absoluteModule = spec?.moduleHref - ? new URL(spec.moduleHref) - : new URL( - codeRefWithAbsoluteURL( - { module, name: exportName }, - new URL(this.selectedRealmURL), - ).module, - ); + const absoluteModuleHref = ( + codeRefWithAbsoluteURL( + { + module: spec?.moduleHref ?? module, + name: exportName, + }, + new URL(this.selectedRealmURL), + ) as ResolvedCodeRef + ).module; + const absoluteModule = new URL(absoluteModuleHref); let moduleURL = maybeRelativeURL( absoluteModule, url, From 7e474346b2f2f8aee06deaf70f17299fee12339d Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 8 Apr 2026 21:40:49 +0800 Subject: [PATCH 4/6] add regression test --- .../code-submode/create-file-test.gts | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/host/tests/acceptance/code-submode/create-file-test.gts b/packages/host/tests/acceptance/code-submode/create-file-test.gts index 295e34792d4..94721fae66b 100644 --- a/packages/host/tests/acceptance/code-submode/create-file-test.gts +++ b/packages/host/tests/acceptance/code-submode/create-file-test.gts @@ -9,7 +9,12 @@ import { import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; -import { baseRealm, Deferred } from '@cardstack/runtime-common'; +import { + baseRealm, + Deferred, + registerCardReferencePrefix, + unregisterCardReferencePrefix, +} from '@cardstack/runtime-common'; import type FileUploadService from '@cardstack/host/services/file-upload'; @@ -37,6 +42,8 @@ import type { TestRealmAdapter } from '../../helpers/adapter'; const testRealmURL2 = 'http://test-realm/test2/'; const testRealmAIconURL = 'https://i.postimg.cc/L8yXRvws/icon.png'; +const testPrefixRealmURL2 = `@test-realm/test2/`; + const files: Record = { '.realm.json': { name: 'Test Workspace A', @@ -183,6 +190,32 @@ const filesB: Record = { }, }, }, + 'animal.gts': ` + import { contains, field, CardDef } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class Animal extends CardDef { + static displayName = 'Animal'; + @field name = contains(StringField); + } + `, + 'spec/animal.json': { + data: { + type: 'card', + attributes: { + cardTitle: 'Animal', + cardDescription: 'Spec for Animal', + specType: 'card', + ref: { module: '@test-realm/test2/animal', name: 'Animal' }, + }, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/spec', + name: 'Spec', + }, + }, + }, + }, }; module('Acceptance | code submode | create-file tests', function (hooks) { @@ -219,6 +252,10 @@ module('Acceptance | code submode | create-file tests', function (hooks) { let { setRealmPermissions, createAndJoinRoom } = mockMatrixUtils; + hooks.before(function () { + registerCardReferencePrefix(testPrefixRealmURL2, testRealmURL2); + }); + hooks.beforeEach(async function () { ({ adapter } = await withCachedRealmSetup(async () => { await setupAcceptanceTestRealm({ @@ -251,6 +288,9 @@ module('Acceptance | code submode | create-file tests', function (hooks) { ); }); + hooks.after(function () { + unregisterCardReferencePrefix(testPrefixRealmURL2); + }); module('when user has permissions to both test realms', function (hooks) { hooks.beforeEach(async function () { setRealmPermissions({ @@ -1234,6 +1274,54 @@ export class TestCard extends CardDef { }); }); + test('can create new card definition in workspace A that extends a card from workspace B via prefix-form ref', async function (assert) { + assert.expect(2); + await visitOperatorMode(`${baseRealm.url}card-api.gts`); + await openNewFileModal('Card Definition'); + await click('[data-test-select-card-type]'); + await waitFor('[data-test-card-catalog-modal]'); + await waitFor( + `[data-test-card-catalog-item="${testRealmURL2}spec/animal"]`, + ); + await click(`[data-test-card-catalog-item="${testRealmURL2}spec/animal"]`); + await click('[data-test-card-catalog-go-button]'); + await waitFor(`[data-test-selected-type="Animal"]`); + + await fillIn('[data-test-display-name-field]', 'Test Card'); + await fillIn('[data-test-file-name-field]', 'test-card'); + + let deferred = new Deferred(); + this.onSave((url, content) => { + if (typeof content !== 'string') { + throw new Error(`expected string save data`); + } + assert.strictEqual( + content, + ` +import { Animal } from '${testRealmURL2}animal'; +import { Component } from 'https://cardstack.com/base/card-api'; +export class TestCard extends Animal { + static displayName = "Test Card"; +}`.trim(), + 'The source uses the resolved absolute module URL', + ); + assert.strictEqual( + url.href, + `${testRealmURL}test-card.gts`, + [ + 'Saved file URL should point to Test Workspace A', + `Expected: ${testRealmURL}test-card.gts`, + `Actual: ${url.href}`, + ].join('\n'), + ); + deferred.fulfill(); + }); + + await click('[data-test-create-definition]'); + await waitFor('[data-test-create-file-modal]', { count: 0 }); + await deferred.promise; + }); + module( 'when the user lacks write permissions in remote realm', function (hooks) { From 9d50aeb116ddc7aba7565577927f6fb51ffa22f7 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 8 Apr 2026 22:12:43 +0800 Subject: [PATCH 5/6] Scope prefix ref registration to its regression test module --- .../code-submode/create-file-test.gts | 95 ++++++++++--------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/packages/host/tests/acceptance/code-submode/create-file-test.gts b/packages/host/tests/acceptance/code-submode/create-file-test.gts index 94721fae66b..212fa105d13 100644 --- a/packages/host/tests/acceptance/code-submode/create-file-test.gts +++ b/packages/host/tests/acceptance/code-submode/create-file-test.gts @@ -252,10 +252,6 @@ module('Acceptance | code submode | create-file tests', function (hooks) { let { setRealmPermissions, createAndJoinRoom } = mockMatrixUtils; - hooks.before(function () { - registerCardReferencePrefix(testPrefixRealmURL2, testRealmURL2); - }); - hooks.beforeEach(async function () { ({ adapter } = await withCachedRealmSetup(async () => { await setupAcceptanceTestRealm({ @@ -288,9 +284,6 @@ module('Acceptance | code submode | create-file tests', function (hooks) { ); }); - hooks.after(function () { - unregisterCardReferencePrefix(testPrefixRealmURL2); - }); module('when user has permissions to both test realms', function (hooks) { hooks.beforeEach(async function () { setRealmPermissions({ @@ -1274,52 +1267,64 @@ export class TestCard extends CardDef { }); }); - test('can create new card definition in workspace A that extends a card from workspace B via prefix-form ref', async function (assert) { - assert.expect(2); - await visitOperatorMode(`${baseRealm.url}card-api.gts`); - await openNewFileModal('Card Definition'); - await click('[data-test-select-card-type]'); - await waitFor('[data-test-card-catalog-modal]'); - await waitFor( - `[data-test-card-catalog-item="${testRealmURL2}spec/animal"]`, - ); - await click(`[data-test-card-catalog-item="${testRealmURL2}spec/animal"]`); - await click('[data-test-card-catalog-go-button]'); - await waitFor(`[data-test-selected-type="Animal"]`); + module('when a selected spec uses a prefix-form ref', function (hooks) { + hooks.beforeEach(function () { + registerCardReferencePrefix(testPrefixRealmURL2, testRealmURL2); + }); - await fillIn('[data-test-display-name-field]', 'Test Card'); - await fillIn('[data-test-file-name-field]', 'test-card'); + hooks.afterEach(function () { + unregisterCardReferencePrefix(testPrefixRealmURL2); + }); - let deferred = new Deferred(); - this.onSave((url, content) => { - if (typeof content !== 'string') { - throw new Error(`expected string save data`); - } - assert.strictEqual( - content, - ` + test('can create new card definition in workspace A that extends a card from workspace B via prefix-form ref', async function (assert) { + assert.expect(2); + await visitOperatorMode(`${baseRealm.url}card-api.gts`); + await openNewFileModal('Card Definition'); + await click('[data-test-select-card-type]'); + await waitFor('[data-test-card-catalog-modal]'); + await waitFor( + `[data-test-card-catalog-item="${testRealmURL2}spec/animal"]`, + ); + await click( + `[data-test-card-catalog-item="${testRealmURL2}spec/animal"]`, + ); + await click('[data-test-card-catalog-go-button]'); + await waitFor(`[data-test-selected-type="Animal"]`); + + await fillIn('[data-test-display-name-field]', 'Test Card'); + await fillIn('[data-test-file-name-field]', 'test-card'); + + let deferred = new Deferred(); + this.onSave((url, content) => { + if (typeof content !== 'string') { + throw new Error(`expected string save data`); + } + assert.strictEqual( + content, + ` import { Animal } from '${testRealmURL2}animal'; import { Component } from 'https://cardstack.com/base/card-api'; export class TestCard extends Animal { static displayName = "Test Card"; }`.trim(), - 'The source uses the resolved absolute module URL', - ); - assert.strictEqual( - url.href, - `${testRealmURL}test-card.gts`, - [ - 'Saved file URL should point to Test Workspace A', - `Expected: ${testRealmURL}test-card.gts`, - `Actual: ${url.href}`, - ].join('\n'), - ); - deferred.fulfill(); - }); + 'The source uses the resolved absolute module URL', + ); + assert.strictEqual( + url.href, + `${testRealmURL}test-card.gts`, + [ + 'Saved file URL should point to Test Workspace A', + `Expected: ${testRealmURL}test-card.gts`, + `Actual: ${url.href}`, + ].join('\n'), + ); + deferred.fulfill(); + }); - await click('[data-test-create-definition]'); - await waitFor('[data-test-create-file-modal]', { count: 0 }); - await deferred.promise; + await click('[data-test-create-definition]'); + await waitFor('[data-test-create-file-modal]', { count: 0 }); + await deferred.promise; + }); }); module( From 9a9c72594a7617acfae053437cf32de02e489a06 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 8 Apr 2026 22:45:09 +0800 Subject: [PATCH 6/6] fix host test --- .../host/tests/acceptance/code-submode/create-file-test.gts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/tests/acceptance/code-submode/create-file-test.gts b/packages/host/tests/acceptance/code-submode/create-file-test.gts index 212fa105d13..3bff0c60e39 100644 --- a/packages/host/tests/acceptance/code-submode/create-file-test.gts +++ b/packages/host/tests/acceptance/code-submode/create-file-test.gts @@ -1268,11 +1268,11 @@ export class TestCard extends CardDef { }); module('when a selected spec uses a prefix-form ref', function (hooks) { - hooks.beforeEach(function () { + hooks.before(function () { registerCardReferencePrefix(testPrefixRealmURL2, testRealmURL2); }); - hooks.afterEach(function () { + hooks.after(function () { unregisterCardReferencePrefix(testPrefixRealmURL2); });