Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 95 additions & 3 deletions packages/cli/src/create/__tests__/org-manifest.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createHash } from 'node:crypto';

import { createTarGzip } from 'nanotar';
import { afterEach, describe, expect, it, vi } from 'vitest';

import {
Expand Down Expand Up @@ -138,6 +141,8 @@ describe('filterManifestForContext', () => {
});
});

const TARBALL_URL = 'https://registry.npmjs.org/@your-org/create/-/create-1.0.0.tgz';

function packument(
vpTemplates: unknown,
extra: Record<string, unknown> = {},
Expand All @@ -150,7 +155,7 @@ function packument(
'1.0.0': {
version: '1.0.0',
dist: {
tarball: 'https://registry.npmjs.org/@your-org/create/-/create-1.0.0.tgz',
tarball: TARBALL_URL,
integrity: 'sha512-fake',
},
createConfig: vpTemplates !== undefined ? { templates: vpTemplates } : undefined,
Expand All @@ -171,6 +176,37 @@ function mockFetchJson(body: unknown, status = 200): ReturnType<typeof vi.spyOn>
} as unknown as Response);
}

/** Resolve the requested URL from any of `fetch`'s accepted input shapes. */
function requestUrl(input: string | URL | Request): string {
return typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
}

/** A real npm-pack-shaped tarball containing only a package.json. */
async function tarballWith(packageJson: unknown): Promise<Uint8Array> {
return await createTarGzip([
{
name: 'package/package.json',
data: new TextEncoder().encode(JSON.stringify(packageJson)),
},
]);
}

/**
* Mock fetch to serve the packument for the registry URL and a real tarball
* `Response` (with a streamable body) for the `.tgz` URL.
*/
function mockFetchPackumentAndTarball(
packumentBody: unknown,
tarBytes: Uint8Array,
): ReturnType<typeof vi.spyOn> {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (requestUrl(input).endsWith('.tgz')) {
return new Response(tarBytes.slice().buffer, { status: 200 });
}
return new Response(JSON.stringify(packumentBody), { status: 200 });
});
}

describe('readOrgManifest', () => {
afterEach(() => {
vi.restoreAllMocks();
Expand All @@ -181,8 +217,64 @@ describe('readOrgManifest', () => {
expect(await readOrgManifest('@your-org')).toBeNull();
});

it('returns null when the package has no createConfig.templates field', async () => {
mockFetchJson(packument(undefined));
it('returns null when neither the packument nor the tarball has createConfig.templates', async () => {
// No `createConfig` in the packument triggers the tarball fallback; the
// tarball's package.json lacks the field too, so the result is still null.
const tarBytes = await tarballWith({ name: '@your-org/create', version: '1.0.0' });
const spy = mockFetchPackumentAndTarball(
packument(undefined, { dist: { tarball: TARBALL_URL } }),
tarBytes,
);
expect(await readOrgManifest('@your-org')).toBeNull();
expect(spy).toHaveBeenCalledTimes(2);
});

it('falls back to the tarball package.json when the registry strips createConfig from the packument', async () => {
// GitHub Packages (and potentially other registries) omit custom fields
// from packument version metadata while the published tarball keeps the
// full package.json.
const tarBytes = await tarballWith({
name: '@your-org/create',
version: '1.0.0',
createConfig: {
templates: [{ name: 'web', description: 'Web app', template: './templates/web' }],
},
});
const integrity = `sha512-${createHash('sha512').update(tarBytes).digest('base64')}`;
const spy = mockFetchPackumentAndTarball(
packument(undefined, { dist: { tarball: TARBALL_URL, integrity } }),
tarBytes,
);
const manifest = await readOrgManifest('@your-org');
expect(manifest).not.toBeNull();
expect(manifest?.templates).toEqual([
{ name: 'web', description: 'Web app', template: './templates/web' },
]);
expect(manifest?.tarballUrl).toBe(TARBALL_URL);
expect(spy).toHaveBeenCalledTimes(2);
});

it('does not download the tarball when the packument carries the manifest', async () => {
const spy = mockFetchJson(
packument([{ name: 'web', description: 'Web app', template: './templates/web' }]),
);
expect(await readOrgManifest('@your-org')).not.toBeNull();
expect(spy).toHaveBeenCalledTimes(1);
});

it('treats a tarball probe failure as "no manifest" so passthrough still works', async () => {
// A normal @scope/create package (no manifest anywhere) whose tarball
// cannot be probed — e.g. a download error — must not turn into a hard
// failure; `null` lets the caller fall through to the passthrough path.
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
if (requestUrl(input).endsWith('.tgz')) {
throw new Error('network unreachable');
}
return new Response(
JSON.stringify(packument(undefined, { dist: { tarball: TARBALL_URL } })),
{ status: 200 },
);
});
expect(await readOrgManifest('@your-org')).toBeNull();
});

Expand Down
23 changes: 22 additions & 1 deletion packages/cli/src/create/org-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'node:path';

import { fetchNpmResource, getNpmRegistry } from '../utils/npm-config.ts';
import { readPackageJsonFromTarball } from './org-tarball.ts';

/**
* A single template entry shared by org manifests (`createConfig.templates`)
Expand Down Expand Up @@ -291,6 +292,11 @@ async function fetchPackument(
* - the package does not exist on the registry (404), or
* - the package exists but has no `createConfig.templates` field
*
* When the packument version metadata lacks `createConfig` entirely, the
* published tarball's package.json is consulted before giving up — some
* registries (GitHub Packages among them) strip custom fields from version
* metadata while preserving the tarball byte-for-byte.
*
* Throws when:
* - the `createConfig.templates` field is present but malformed (`OrgManifestSchemaError`), or
* - the registry request fails for any non-404 reason
Expand Down Expand Up @@ -331,7 +337,22 @@ export async function readOrgManifest(
if (!meta) {
return null;
}
const templates = validateManifest(meta, packageName);
let templates = validateManifest(meta, packageName);
if (!templates && meta.createConfig === undefined && meta.dist?.tarball) {
// `createConfig` absent (not merely empty) means the registry either
// stripped it from the version metadata or the package has no manifest;
// probe the published tarball to tell the two apart (see
// `readPackageJsonFromTarball`). The probe is best-effort: any
// download/integrity/parse failure degrades to "no manifest" so a normal
// `@scope/create` package still reaches the passthrough path. A manifest
// that IS present but malformed still throws, because `validateManifest`
// runs outside the catch.
const packageJson = await readPackageJsonFromTarball(
meta.dist.tarball,
meta.dist.integrity,
).catch(() => null);
templates = validateManifest(packageJson, packageName);
}
if (!templates) {
return null;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/create/org-tarball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,38 @@ async function downloadTarball(url: string): Promise<Uint8Array> {
return bytes;
}

/**
* Download a package tarball and parse its `package/package.json`.
*
* Some registries (GitHub Packages among them) strip fields they don't
* recognize — including `createConfig` — from packument *version metadata*
* while preserving the published tarball byte-for-byte. This gives
* `readOrgManifest` a fallback source of truth for those registries.
*
* Returns `null` when the archive contains no `package/package.json`.
* Throws on download/integrity failures or unparsable JSON.
*/
export async function readPackageJsonFromTarball(
tarballUrl: string,
integrity?: string,
): Promise<unknown> {
const bytes = await downloadTarball(tarballUrl);
verifyIntegrity(bytes, integrity);
const entries = await parseTarGzip(bytes);
for (const entry of entries) {
if (normalizeEntryName(entry.name) !== 'package.json' || !entry.data) {
continue;
}
const text = new TextDecoder().decode(entry.data);
try {
return JSON.parse(text) as unknown;
} catch {
throw new Error(`invalid package.json in tarball: ${tarballUrl}`);
}
}
return null;
}

const STAGING_SUFFIX_PREFIX = '.tmp-';

/**
Expand Down
Loading