Skip to content

Commit 58f4142

Browse files
committed
Add scoped-name registry routing, fix cachePut parent-dir creation for slash-containing identities, and tighten scoped source parsing
1 parent 03cbcd4 commit 58f4142

15 files changed

Lines changed: 536 additions & 127 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-06-15

packages/cli/src/commands/publish/__tests__/publish.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -511,18 +511,17 @@ describe('publishCommand — registry interaction (preserved scenarios)', () =>
511511
expect(stdout).toContain('an admin will review your submission shortly')
512512
})
513513

514-
test('scoped facet: URL-encodes the scoped identity', async () => {
514+
test('scoped facet: uploads via the two-segment scoped route (no %2F)', async () => {
515515
// The facet identity grammar (FacetManifestSchema) accepts an unscoped
516516
// slug (`cowsay`) or a scoped `@scope/name` (`@acme/cowsay`); the legacy
517517
// bare-slash form (`acme/cowsay`) is no longer a valid manifest name.
518518
//
519-
// TODO(scoped-routes): the registry has moved to literal-slash scoped
520-
// routes (`/v0/facets/@scope/name/...`), but the engine registry client
521-
// still hits the single-`{name}` route and lets openapi-fetch percent-
522-
// encode the whole identity (`@acme/cowsay` → `%40acme%2Fcowsay`). The
523-
// scoped-route migration (publish/resolve/download) is tracked separately;
524-
// this assertion documents the *current* client behavior and is expected
525-
// to flip to the literal-slash form when that lands.
519+
// The publish client routes scoped names through the registry's
520+
// two-segment scoped route (`/v0/facets/{scope}/{name}/versions`) so the
521+
// scope `/` stays a literal path separator. The scope marker is its own
522+
// segment (`%40acme`) and the name is a separate segment (`cowsay`); the
523+
// slash between them is NOT percent-encoded to `%2F` (which the registry
524+
// rejects with E_INVALID_NAME).
526525
await buildFacetFixture(projectRoot, {
527526
name: '@acme/cowsay',
528527
version: '0.1.0',
@@ -541,7 +540,8 @@ describe('publishCommand — registry interaction (preserved scenarios)', () =>
541540
expect(spy.calls).toHaveLength(1)
542541
const call = spy.calls[0]
543542
if (call === undefined) expect.unreachable()
544-
expect(call.url).toBe('https://api.test/v0/facets/%40acme%2Fcowsay/versions')
543+
expect(call.url).toBe('https://api.test/v0/facets/%40acme/cowsay/versions')
544+
expect(call.url).not.toContain('%2F')
545545
})
546546

547547
test('413 (tarball too large): renders the registry error verbatim', async () => {

packages/engine/src/__tests__/cache.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,28 @@ describe('cachePut', () => {
198198
expect(cacheSlotIsDir(b)).toBe(true)
199199
})
200200

201+
// Slash-containing identities (scoped `@scope/name` and namespaced
202+
// `acme/name`) render as nested slot paths under the cache root. cachePut
203+
// must create the slot's parent directory before the rename, or the
204+
// rename fails with ENOENT. Goes through cachePut (not seedSlot, which
205+
// mkdir -p's the full path and would mask the defect).
206+
test.each([
207+
['scoped', '@julian/cowsay'],
208+
['namespaced unscoped', 'acme/cowsay'],
209+
])('populates a %s slot whose identity contains a slash', (_label, name) => {
210+
const id: CacheIdentity = { kind: 'registry', name, version: '1.0.0' }
211+
const staging = cacheStagingDir()
212+
writeFileSync(join(staging, 'facet.json'), `{"name":"${name}","version":"1.0.0"}`)
213+
const result = cachePut(id, staging)
214+
expect(result.ok).toBe(true)
215+
if (!result.ok) expect.unreachable()
216+
expect(result.path).toBe(cachePath(id))
217+
expect(existsSync(staging)).toBe(false)
218+
expect(cacheSlotIsDir(id)).toBe(true)
219+
expect(cacheGet(id).hit).toBe(true)
220+
expect(existsSync(join(result.path, 'facet.json'))).toBe(true)
221+
})
222+
201223
test('local-source slots disambiguate by absolute path', () => {
202224
const a: CacheIdentity = { kind: 'local', name: 'p', absolutePath: '/path/a' }
203225
const b: CacheIdentity = { kind: 'local', name: 'p', absolutePath: '/path/b' }

packages/engine/src/__tests__/registry.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ describe('resolveRegistryMetadataBatch', () => {
100100
expect(calledUrls[0]).toBe('https://api.test/v0/facets/acme%2Fcowsay/latest')
101101
})
102102

103+
test('scoped names use the two-segment scoped route with an unencoded slash', async () => {
104+
const calledUrls: string[] = []
105+
globalThis.fetch = (async (input: string | URL | Request) => {
106+
calledUrls.push(captureUrl(input))
107+
return new Response(JSON.stringify(fixtures.versionMetadata({ name: '@julian/cowsay', version: '1.2.3' })), {
108+
status: 200,
109+
})
110+
}) as unknown as typeof fetch
111+
await resolveRegistryMetadataBatch([
112+
{ name: '@julian/cowsay', version: { kind: 'exact', major: 1, minor: 2, patch: 3 } },
113+
])
114+
// Scope and name are independent path segments: the scope marker is a
115+
// single segment (`%40julian`) and the slash between scope and name is a
116+
// real path separator — never collapsed into `%2F`.
117+
expect(calledUrls[0]).toBe('https://api.test/v0/facets/%40julian/cowsay/1.2.3')
118+
expect(calledUrls[0]).not.toContain('%2F')
119+
})
120+
103121
test('404 maps to NOT_FOUND with the spec verbatim', async () => {
104122
globalThis.fetch = (async () =>
105123
new Response(JSON.stringify(fixtures.apiError({ error: 'gone', docs_url: 'x' })), {
@@ -251,6 +269,24 @@ describe('downloadAndExtractFacet', () => {
251269
expect(readFileSync(join(dest, 'commands/cowsay.md'), 'utf8')).toContain('# cowsay')
252270
})
253271

272+
test('scoped name uses the two-segment scoped archive route (no %2F)', async () => {
273+
const facetJson = JSON.stringify(
274+
{ name: '@julian/cowsay', version: '0.1.0', commands: { cowsay: { description: 'Say moo' } } },
275+
null,
276+
2,
277+
)
278+
const { bytes, integrity } = buildArchive([
279+
{ path: 'facet.json', content: facetJson },
280+
{ path: 'commands/cowsay.md', content: '# cowsay\n' },
281+
])
282+
const { urls } = stubArchiveDownload(new Response(bytes, { status: 200 }))
283+
const result = await downloadAndExtractFacet({ ...META, name: '@julian/cowsay', transportHash: integrity }, dest)
284+
expect(result.ok).toBe(true)
285+
expect(urls[0]).toBe('https://api.test/v0/facets/%40julian/cowsay/0.1.0/archive')
286+
expect(urls[0]).not.toContain('%2F')
287+
expect(urls[1]).toBe(S3_URL)
288+
})
289+
254290
test('sha256 mismatch: returns NETWORK_ERROR with hash detail and writes nothing', async () => {
255291
const { bytes } = buildArchive([{ path: 'facet.json', content: JSON.stringify({ name: 'x', version: '0.1.0' }) }])
256292
stubArchiveDownload(new Response(bytes, { status: 200 }))

packages/engine/src/cache/operations.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
statSync,
1010
writeFileSync,
1111
} from 'node:fs'
12-
import { join } from 'node:path'
12+
import { dirname, join } from 'node:path'
1313
import { validateAssetName } from '@agent-facets/common'
1414
import type {
1515
AssetIntegrityFailure,
@@ -145,8 +145,14 @@ export function cacheGet(identity: CacheIdentity): CacheLookup {
145145
*/
146146
export function cachePut(identity: CacheIdentity, sourceDir: string): CachePutResult {
147147
const finalPath = cachePath(identity)
148-
const root = resolveCacheRoot()
149-
mkdirSync(root, { recursive: true })
148+
// Create the slot's PARENT directory, not just the cache root. For a flat
149+
// (unscoped, slashless) identity `dirname(finalPath)` is the cache root, so
150+
// this also covers the common case. For a slash-containing identity —
151+
// scoped `@scope/name@version` or namespaced `acme/name@version` — the slot
152+
// renders as a nested path (`<root>/@scope/name@version`), whose parent
153+
// (`<root>/@scope`) does not exist by default; without this the renameSync
154+
// below fails with ENOENT.
155+
mkdirSync(dirname(finalPath), { recursive: true })
150156

151157
if (existsSync(finalPath)) {
152158
if (!isExistingDirectory(finalPath)) {

packages/engine/src/install/__tests__/run-add.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,30 @@ describe('runAdd — registry manifest-value rule', () => {
212212
await add('cowsay')
213213
expect(readFacets().cowsay).not.toBe('cowsay')
214214
})
215+
216+
test('scoped bare name pins the resolved exact version under the scoped key', async () => {
217+
registryResolvedVersion = '0.1.1'
218+
registryFixtureDir = buildFixture(fakeHome, '@julian/cowsay', '0.1.1')
219+
const result = await add('@julian/cowsay')
220+
expect(result.ok).toBe(true)
221+
expect(readFacets()['@julian/cowsay']).toBe('0.1.1')
222+
})
223+
224+
test('scoped explicit @latest is written verbatim and floats', async () => {
225+
registryResolvedVersion = '0.1.1'
226+
registryFixtureDir = buildFixture(fakeHome, '@julian/cowsay', '0.1.1')
227+
const result = await add('@julian/cowsay@latest')
228+
expect(result.ok).toBe(true)
229+
expect(readFacets()['@julian/cowsay']).toBe('latest')
230+
})
231+
232+
test('scoped explicit exact version is recorded as written', async () => {
233+
registryResolvedVersion = '0.1.1'
234+
registryFixtureDir = buildFixture(fakeHome, '@julian/cowsay', '0.1.1')
235+
const result = await add('@julian/cowsay@0.1.1')
236+
expect(result.ok).toBe(true)
237+
expect(readFacets()['@julian/cowsay']).toBe('0.1.1')
238+
})
215239
})
216240

217241
describe('runAdd — git/local manifest-value rule', () => {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Route-shape tests for `publishFacetVersion`.
3+
*
4+
* Scoped facet names MUST publish through the registry's two-segment scoped
5+
* route (`/v0/facets/{scope}/{name}/versions`) so the scope `/` stays a
6+
* literal path separator. Unscoped names keep the single-`{name}` route.
7+
* These tests stub `fetch` and assert on the request URL shape.
8+
*/
9+
10+
import { describe, expect, test } from 'bun:test'
11+
import { createRegistryClient } from '../client.ts'
12+
import { publishResponse } from '../fixtures.ts'
13+
import { publishFacetVersion } from '../publish.ts'
14+
15+
function asFetch(
16+
fn: (input: Request | string | URL, init?: RequestInit) => Promise<Response>,
17+
): typeof globalThis.fetch {
18+
return fn as unknown as typeof globalThis.fetch
19+
}
20+
21+
const BASE_URL = 'https://api.test'
22+
23+
function stubPublish(): { urls: string[]; fetch: typeof globalThis.fetch } {
24+
const urls: string[] = []
25+
const fetch = asFetch(async (input) => {
26+
urls.push(input instanceof Request ? input.url : String(input))
27+
return new Response(JSON.stringify(publishResponse()), {
28+
status: 201,
29+
headers: { 'content-type': 'application/json' },
30+
})
31+
})
32+
return { urls, fetch }
33+
}
34+
35+
describe('publishFacetVersion — route selection', () => {
36+
test('unscoped name posts to the single-{name} versions route', async () => {
37+
const { urls, fetch } = stubPublish()
38+
const client = createRegistryClient({ baseUrl: BASE_URL, fetch })
39+
const result = await publishFacetVersion(client, { name: 'cowsay', tarball: new Uint8Array([1, 2, 3]) })
40+
expect(result.ok).toBe(true)
41+
expect(urls[0]).toBe('https://api.test/v0/facets/cowsay/versions')
42+
})
43+
44+
test('scoped name posts to the two-segment scoped versions route (no %2F)', async () => {
45+
const { urls, fetch } = stubPublish()
46+
const client = createRegistryClient({ baseUrl: BASE_URL, fetch })
47+
const result = await publishFacetVersion(client, {
48+
name: '@julian/cowsay',
49+
tarball: new Uint8Array([1, 2, 3]),
50+
})
51+
expect(result.ok).toBe(true)
52+
expect(urls[0]).toBe('https://api.test/v0/facets/%40julian/cowsay/versions')
53+
expect(urls[0]).not.toContain('%2F')
54+
})
55+
})

packages/engine/src/registry/download.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createRegistryClient, translateWireError } from './client.ts'
77
import { resolveCredential } from './credentials.ts'
88
import type { paths } from './generated/registry-api.ts'
99
import { uncappedGunzip } from './gunzip.ts'
10+
import { facetNameToRoute } from './http.ts'
1011
import type { RegistryMetadata, RegistryResult } from './types.ts'
1112

1213
/**
@@ -178,13 +179,22 @@ export async function resolveArchiveUrl(
178179
// body stream is consumed in the process), so we capture it here and
179180
// render it below rather than re-reading `response.json()`.
180181
let wireError: unknown
182+
// Scoped names use the two-segment scoped archive route so the scope `/`
183+
// is never collapsed into `%2F` (which the registry rejects).
184+
const route = facetNameToRoute(meta.name)
181185
try {
182-
const result = await client.GET('/v0/facets/{name}/{version}/archive', {
183-
params: { path: { name: meta.name, version: meta.version } },
184-
// Do not follow the redirect inside the typed client; we want to
185-
// read the presigned S3 URL off the `Location` header ourselves.
186-
redirect: 'manual',
187-
})
186+
const result =
187+
route.kind === 'scoped'
188+
? await client.GET('/v0/facets/{scope}/{name}/{version}/archive', {
189+
params: { path: { scope: route.scope, name: route.name, version: meta.version } },
190+
redirect: 'manual',
191+
})
192+
: await client.GET('/v0/facets/{name}/{version}/archive', {
193+
params: { path: { name: route.name, version: meta.version } },
194+
// Do not follow the redirect inside the typed client; we want to
195+
// read the presigned S3 URL off the `Location` header ourselves.
196+
redirect: 'manual',
197+
})
188198
response = result.response
189199
wireError = result.error
190200
} catch (err) {

0 commit comments

Comments
 (0)